Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.5.0
version: v2.13.2
18 changes: 9 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
Expand All @@ -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
)
32 changes: 16 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
7 changes: 7 additions & 0 deletions internal/tui/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
23 changes: 21 additions & 2 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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 —
Expand Down
1 change: 1 addition & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions internal/tui/theme_regression_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down