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 {