From 45a9fe3369d21da27bf7bbbfd11ee5470c39a08d Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 23:22:31 +0400 Subject: [PATCH] fix(tui): repaint the background after inner ANSI resets, and guard it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping already-styled text in a Background() style silently fails: an inner "\x1b[0m" (glamour emits one per span) resets the background to the *terminal* default, not to the enclosing lipgloss style, so every cell after the first reset went transparent and the terminal wallpaper showed through. fillFrame only pads tail gaps, so it never caught this. paintBG splits on the reset and re-applies the background per segment, leaving each segment's own foreground codes intact. Used for the markdown-rendered router turn, whose old comment claimed the wrapping approach "keeps the opaque panel" — it did not. TestFrameHasNoTransparentCells asserts the property directly over every render-matrix case: after any reset, no printable cell before a background SGR. Verified non-vacuous — restoring the old line fails it on chat.turn (router). This is the part that stops the next render site from reintroducing it. Co-Authored-By: Claude Opus 5 --- apps/tui-go/internal/app/opaque_bg_test.go | 89 ++++++++++++++++++++++ apps/tui-go/internal/app/view.go | 30 +++++++- 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 apps/tui-go/internal/app/opaque_bg_test.go diff --git a/apps/tui-go/internal/app/opaque_bg_test.go b/apps/tui-go/internal/app/opaque_bg_test.go new file mode 100644 index 00000000..fec27407 --- /dev/null +++ b/apps/tui-go/internal/app/opaque_bg_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "regexp" + "strings" + "testing" +) + +// opaque_bg_test.go is the backdrop-opacity guard. +// +// The TUI runs in terminals with a transparent/wallpapered background, so every cell the +// frame occupies must carry an explicit background SGR. The recurring bug is not a missing +// Background() call — it's wrapping ALREADY-STYLED text in one: an inner "\x1b[0m" resets +// the background to the terminal default, not to the enclosing lipgloss style, so the rest +// of the line goes transparent. fillFrame only pads tail gaps, so it never catches this. +// +// This asserts the property directly on the rendered frame: after any reset, no printable +// character may appear before a background SGR re-establishes the backdrop. + +// bgSGR matches a background color introduction: 4x/10x (basic/bright), 48;5;N (256), +// or 48;2;R;G;B (truecolor) — anywhere in a multi-parameter SGR sequence. +var bgSGR = regexp.MustCompile(`\x1b\[[0-9;]*?(?:4[0-7]|10[0-7]|48;[25])[0-9;]*m`) + +var anySGR = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// firstTransparentRun returns the first printable run in line that is rendered with no +// background set, or "" when every printable cell is backed. Leading/trailing whitespace +// outside any SGR is ignored only when the line is entirely blank. +func firstTransparentRun(line string) string { + if strings.TrimSpace(anySGR.ReplaceAllString(line, "")) == "" { + return "" // blank row; fillFrame pads it + } + bgActive := false + pos := 0 + for _, loc := range anySGR.FindAllStringIndex(line, -1) { + if text := line[pos:loc[0]]; strings.TrimSpace(text) != "" && !bgActive { + return text + } + seq := line[loc[0]:loc[1]] + switch { + case bgSGR.MatchString(seq): + bgActive = true + case seq == "\x1b[0m" || seq == "\x1b[m": + bgActive = false + } + pos = loc[1] + } + if text := line[pos:]; strings.TrimSpace(text) != "" && !bgActive { + return text + } + return "" +} + +func TestPaintBGSurvivesInnerResets(t *testing.T) { + // A pre-styled string shaped like glamour output: styled span, reset, more text. + pre := "\x1b[1mbold\x1b[0m plain \x1b[36mcyan\x1b[0m tail" + + if got := firstTransparentRun(pre); got == "" { + t.Fatal("fixture is already opaque — it cannot prove paintBG does anything") + } + + painted := paintBG(pre, newMatrixModel().theme.P.Bg) + if got := firstTransparentRun(painted); got != "" { + t.Errorf("paintBG left a transparent run %q\nin: %q", got, painted) + } + + // The bug this replaces: wrapping pre-styled ANSI in Background().Render. + if strings.TrimSpace(anySGR.ReplaceAllString(painted, "")) != "bold plain cyan tail" { + t.Errorf("paintBG altered visible text: %q", anySGR.ReplaceAllString(painted, "")) + } +} + +func TestFrameHasNoTransparentCells(t *testing.T) { + for _, tc := range matrixCases() { + t.Run(tc.name, func(t *testing.T) { + m := newMatrixModel() + if tc.prep != nil { + tc.prep(&m) + } + m.applyServer(tc.build()) + + for i, ln := range strings.Split(m.render(), "\n") { + if got := firstTransparentRun(ln); got != "" { + t.Errorf("row %d has no background under %q\nrow: %q", i, got, ln) + } + } + }) + } +} diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6b4c12f3..d7942b33 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -811,12 +811,13 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) { case "router": // The router turn is model output: render it as markdown (bold, // lists, headings, code) wrapped to the panel width. glamour applies - // its own inline foreground colors; we keep the opaque panel by - // fixing each line's background. On any failure renderMarkdown hands - // back the plain content, which still flows through this path fine. + // its own inline foreground colors *and* emits a reset after each + // span, so the panel background has to be repainted per segment — + // paintBG, not Background().Render. On any failure renderMarkdown + // hands back the plain content, which still flows through fine. rendered := renderMarkdown(e.Content, w) for _, ln := range strings.Split(rendered, "\n") { - rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln)) + rows = append(rows, paintBG(ln, t.P.Bg)) } if s := metricsSuffix(e.Metrics); s != "" { rows = append(rows, t.span(s, t.P.Faint)) @@ -1085,6 +1086,27 @@ func padTo(s string, w int, bg color.Color) string { return s + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", w-vw)) } +// paintBG makes an ALREADY-STYLED string opaque. Wrapping pre-rendered ANSI in a +// Background() style does not work: an inner "\x1b[0m" (glamour emits one per styled +// span) resets the background to the *terminal* default, not to the enclosing lipgloss +// style, so every cell after the first reset goes transparent. Splitting on the reset +// and re-applying the background to each segment repaints those cells while leaving each +// segment's own foreground codes intact. +// +// Use this instead of Background().Render(s) whenever s may already contain ANSI — +// markdown, syntax-highlighted, or otherwise pre-composed content. +func paintBG(s string, bg color.Color) string { + s = strings.ReplaceAll(s, "\x1b[m", "\x1b[0m") // normalize the short reset form + st := lipgloss.NewStyle().Background(bg) + parts := strings.Split(s, "\x1b[0m") + for i, p := range parts { + if p != "" { + parts[i] = st.Render(p) + } + } + return strings.Join(parts, "") +} + // padRaw pads a plain (unstyled) string to width w with spaces, truncating with // an ellipsis only when it genuinely overflows. func padRaw(s string, w int) string {