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
+26 -4
View File
@@ -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 {