diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 007dbdd..f71f717 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,4 +82,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.5.0 + version: v2.13.2 diff --git a/go.mod b/go.mod index 0ad82c0..1c67db4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/BackendStack21/bodek -go 1.25.0 +go 1.26.0 require ( github.com/charmbracelet/bubbles v1.0.0 @@ -11,8 +11,8 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/muesli/cancelreader v0.2.2 github.com/muesli/termenv v0.16.0 - golang.org/x/net v0.58.0 - golang.org/x/sys v0.47.0 + golang.org/x/net v0.59.0 + golang.org/x/sys v0.48.0 ) require ( @@ -22,23 +22,23 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/dlclark/regexp2/v2 v2.7.1 // indirect + github.com/dlclark/regexp2/v2 v2.8.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.28 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v1.0.0 // indirect - github.com/yuin/goldmark v1.8.5 // indirect + github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 35ae0d2..9795310 100644 --- a/go.sum +++ b/go.sum @@ -30,16 +30,16 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 h1:Z0hBPQ9hslsfpFRRdMn+4cjnb3LK6FQH58hQkXrXv0A= -github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 h1:zW96vcZ3zDetccerbN7sS/sCydOE/l/9S+C94pozsaU= +github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4= -github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw= +github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -53,8 +53,8 @@ github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= -github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -71,18 +71,18 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= -github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index 2ef1ac0..3b27ec9 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -27,5 +27,12 @@ func (m *Model) paintCanvas(body string) string { base := fg + bg frame = strings.NewReplacer("\x1b[0m", "\x1b[0m"+base, "\x1b[m", "\x1b[m"+base, "\x1b[49m", bg, "\x1b[39m", fg).Replace(frame) + // Reset-at-line-start insurance: a reset at the very end of a row clears + // the background for the whole following row on some terminals, and rows + // that never carried an escape (blank transcript rows, plain text lines, + // bottom padding) were painted by nothing at all — the terminal's own + // background bled through as black stripes on the parchment canvas. + // Every row starts on the canvas. + frame = strings.ReplaceAll(frame, "\n", "\n"+base) return base + frame + "\x1b[0m" } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 35fc091..07c06d8 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -254,6 +254,16 @@ func (m *Model) switchTheme(name string) tea.Cmd { m.ta.FocusedStyle.Placeholder = m.th.inputPlaceholder m.ta.BlurredStyle.Placeholder = m.th.inputPlaceholder m.logoCache = "" // the banner gradient is palette-dependent + // Raw cards are point-in-time snapshots and never go through glamour + // (resize skips them) — the /help card must still follow the palette, so + // regenerate it in place, preserving transcript position. + for i := range m.msgs { + if m.msgs[i].help { + card := m.buildHelpCard() + m.msgs[i].content = card + m.msgs[i].rendered = card + } + } m.resize(m.width, m.height) if m.opts.OnThemeChange != nil { if err := m.opts.OnThemeChange(canonical); err != nil { @@ -266,6 +276,16 @@ func (m *Model) switchTheme(name string) tea.Cmd { // showHelp appends a help card listing commands and key bindings. It is // pre-styled to the brand palette (raw), not glamour's stock dark style. func (m *Model) showHelp() { + card := m.buildHelpCard() + m.msgs = append(m.msgs, message{role: roleAsst, content: card, rendered: card, raw: true, help: true}) + m.refresh() +} + +// buildHelpCard renders the /help card against the active theme. It is a +// point-in-time styled snapshot (raw), so switchTheme regenerates every help +// card in the transcript through this builder instead of leaving it on the +// previous palette. +func (m *Model) buildHelpCard() string { th := m.th // Same framed-card contract as the composer, palette, and drawer: the // box spans the terminal and the rule fills the inner text column. @@ -317,8 +337,7 @@ func (m *Model) showHelp() { } card := th.acBox.Width(m.cardWidth()).Render(b.String()) - m.msgs = append(m.msgs, message{role: roleAsst, content: card, rendered: card, raw: true}) - m.refresh() + return card } // runExport saves the current session transcript next to the user — diff --git a/internal/tui/model.go b/internal/tui/model.go index 524b52f..4914424 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -117,6 +117,7 @@ type message struct { streaming bool stats *turnStats // finalized-turn telemetry; nil while streaming / for history raw bool // content is pre-styled; render verbatim, never re-render + help bool // raw /help card: regenerated (not glamour) on theme switch sentAt time.Time // user turns: when the prompt was submitted (drives the head's age) collapsed bool // turn card folded to its head + summary line (c) systemWake bool // server-initiated turn (background-job wake): marker on the card diff --git a/internal/tui/theme_regression_test.go b/internal/tui/theme_regression_test.go new file mode 100644 index 0000000..d9d28b1 --- /dev/null +++ b/internal/tui/theme_regression_test.go @@ -0,0 +1,102 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// TestEmberLightCodeBlocksNotDark reproduces the "blacks still observed after +// enabling Ember Light" report: answerGlamourStyle adopts glamour's stock +// LightStyleConfig wholesale for ember-light, and that preset paints chroma +// code-block backgrounds #373737 — a near-black panel inside every fenced +// code block rendered on the parchment card. The light theme must not emit +// dark background SGRs for code blocks. +func TestEmberLightCodeBlocksNotDark(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(oldProfile) + + t.Setenv("BODEK_THEME", "ember-light") + r, err := glamour.NewTermRenderer( + glamour.WithStyles(answerGlamourStyle()), + glamour.WithWordWrap(80), + ) + if err != nil { + t.Fatal(err) + } + out, err := r.Render("```go\nfunc main() {}\n```\n") + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "48;2;55;55;55") { // #373737 + t.Errorf("ember-light code block paints glamour's near-black #373737 background:\n%q", out) + } +} + +// TestLightCanvasPaintsEveryRow reproduces "blacks are still observed after +// enabling Ember Light": paintCanvas frames the body with an unstyled +// lipgloss Width/Height style and only re-asserts the canvas after embedded +// SGR resets — rows that contain no escapes at all (blank transcript rows, +// plain text lines, bottom padding) carry no background and fall back to the +// terminal's own (dark) background. Every visible row must paint the canvas. +func TestLightCanvasPaintsEveryRow(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(oldProfile) + + m := newTestModel() + m.th = themeFrom(emberLight) + m.width, m.height = 20, 5 + // Row 2 is completely escape-free, like a plain transcript line; row 4 + // is empty; row 5 only exists as bottom padding. + out := m.paintCanvas("styled \x1b[0mrow\nplain row no escapes\n\nstyled \x1b[0magain") + bg := "\x1b[48;2;250;248;242m" + for i, line := range strings.Split(out, "\n") { + if lipgloss.Width(plain(line)) == 0 && line == "" { + continue + } + if !strings.Contains(line, bg) && !strings.Contains(line, "\x1b[48") { + t.Errorf("row %d paints no canvas background (terminal black bleeds through): %q", i, line) + } + } +} + +// TestHelpCardRethemedAfterSwitch reproduces "Ember Light misses some +// components": the raw /help card is a point-in-time styled snapshot and +// switchTheme's resize() skips raw messages, so after switching themes the +// help card keeps the previous palette's colors. It must re-render with the +// active theme. +func TestHelpCardRethemedAfterSwitch(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(oldProfile) + + old := themeOverride + defer func() { themeOverride = old }() + + m := newTestModel() + themeOverride = "ember-dark" + m.th = themeFrom(emberDark) + m.showHelp() + + rendered, _ := m.renderMessage(m.msgs[len(m.msgs)-1], len(m.msgs)-1, 0) + block := rendered + if !strings.Contains(block, "38;2;168;176;192") { // ember-dark muted #A8B0C0 + t.Fatalf("precondition: help card carries ember-dark muted color:\n%q", block) + } + + m.switchTheme("ember-light") + + rendered, _ = m.renderMessage(m.msgs[len(m.msgs)-1], len(m.msgs)-1, 0) + block = rendered + if strings.Contains(block, "38;2;168;176;192") { + t.Errorf("help card still styled with ember-dark muted after switch to ember-light:\n%q", block) + } + if !strings.Contains(block, "38;2;89;95;109") { // ember-light muted #5A5F6D (lipgloss quantizes g−1) + t.Errorf("help card missing ember-light muted color after switch:\n%q", block) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index d8891fe..e8f8d45 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -848,7 +848,10 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st // still fits; high-contrast skips the surface entirely. Returns the styled // card and its line count. func (m *Model) answerCardBody(body string) (string, int) { - if m.th.answerCard.GetBackground() == nil { + // lipgloss GetBackground returns NoColor{}, never nil, so the empty + // surface (high-contrast) is detected by value — profile-independent, + // unlike the rendered SGR, which is also empty in low-color profiles. + if _, none := m.th.answerCard.GetBackground().(lipgloss.NoColor); none { return body, lineCount(body) } // Glamour resets styling after each span; without re-asserting the