fix(tui): repaint the background after inner ANSI resets, and guard it

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 23:22:31 +04:00
parent 8a9935ab6e
commit 45a9fe3369
2 changed files with 115 additions and 4 deletions
@@ -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)
}
}
})
}
}
+26 -4
View File
@@ -811,12 +811,13 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) {
case "router": case "router":
// The router turn is model output: render it as markdown (bold, // The router turn is model output: render it as markdown (bold,
// lists, headings, code) wrapped to the panel width. glamour applies // lists, headings, code) wrapped to the panel width. glamour applies
// its own inline foreground colors; we keep the opaque panel by // its own inline foreground colors *and* emits a reset after each
// fixing each line's background. On any failure renderMarkdown hands // span, so the panel background has to be repainted per segment —
// back the plain content, which still flows through this path fine. // paintBG, not Background().Render. On any failure renderMarkdown
// hands back the plain content, which still flows through fine.
rendered := renderMarkdown(e.Content, w) rendered := renderMarkdown(e.Content, w)
for _, ln := range strings.Split(rendered, "\n") { 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 != "" { if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint)) 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)) 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 // padRaw pads a plain (unstyled) string to width w with spaces, truncating with
// an ellipsis only when it genuinely overflows. // an ellipsis only when it genuinely overflows.
func padRaw(s string, w int) string { func padRaw(s string, w int) string {