Skip to content

Commit 1b153f1

Browse files
committed
fix(tui): keep the answer card's surface continuous under glamour spans
Glamour resets styling after every span and paints its margin fillers with parent styles, so once a reset landed the card's background was gone: the text rendered on the terminal's own background (gray in most terminals) with blue surviving only in the card's padding and fill. weaveSurface re-asserts the card background after every embedded SGR reset in the glamour output — block-agnostic, so paragraphs, lists, code blocks, and headings all keep the surface. The escape is derived from what the card style itself paints (surfaceSGR), staying consistent with the active color profile; themes without a surface (high-contrast) no-op. Verified with a per-line SGR-state simulation over the preview dumps: zero printable cells inside the card fall back to the terminal default.
1 parent d66cf9d commit 1b153f1

3 files changed

Lines changed: 83 additions & 1 deletion

File tree

‎internal/tui/copyclean_test.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,53 @@ func TestAnswerCard(t *testing.T) {
164164
lipgloss.SetColorProfile(termenv.Ascii)
165165
}
166166

167+
// TestSurfaceWeave pins the card's background continuity: glamour resets
168+
// styling after every span (and paints margin fillers unstyled), so the
169+
// card must re-assert its surface after each embedded reset — otherwise
170+
// the text sits on the terminal's own background and the card breaks up
171+
// into padding-only blue.
172+
func TestSurfaceWeave(t *testing.T) {
173+
lipgloss.SetColorProfile(termenv.TrueColor)
174+
defer lipgloss.SetColorProfile(termenv.Ascii)
175+
176+
// weaveSurface is the exact transformation: reset → reset + surface.
177+
bg := "\x1b[48;2;16;19;26m"
178+
woven := weaveSurface("\x1b[38;2;231;233;238mspan1\x1b[0m gap \x1b[38;2;231;233;238;1mspan2\x1b[0m", bg)
179+
want := "\x1b[38;2;231;233;238mspan1\x1b[0m" + bg + " gap \x1b[38;2;231;233;238;1mspan2\x1b[0m" + bg
180+
if woven != want {
181+
t.Errorf("weave mismatch:\n got %q\nwant %q", woven, want)
182+
}
183+
// No surface sequence — no weaving (high-contrast stays pure text).
184+
if got := weaveSurface("a\x1b[0mb", ""); got != "a\x1b[0mb" {
185+
t.Errorf("empty surface must no-op, got %q", got)
186+
}
187+
// surfaceSGR reads the escape the style really paints — the ember-dark
188+
// card's own blue — and returns nothing when a style paints none.
189+
if got := surfaceSGR(themeFrom(emberDark).answerCard); got != bg {
190+
t.Errorf("ember-dark surface escape = %q, want %q", got, bg)
191+
}
192+
if got := surfaceSGR(themeFrom(emberHighContrast).answerCard); got != "" {
193+
t.Errorf("high-contrast must paint no surface, got %q", got)
194+
}
195+
196+
// End to end: a finalized answer with glamour-style span resets keeps
197+
// the surface under every gap inside the card.
198+
m := newTestModel()
199+
m.msgs = append(m.msgs, message{role: roleAsst,
200+
rendered: "\x1b[38;2;231;233;238mspan1\x1b[0m \x1b[38;2;231;233;238;1mspan2\x1b[0m tail",
201+
items: []turnItem{{thinking: true, text: "hmm"}}})
202+
out, _ := m.renderMessage(m.msgs[0], 0, 0)
203+
// Every embedded reset inside the card region is followed by the
204+
// surface re-assert (before the card's own line-end resets, which may
205+
// legitimately trail) — count re-asserts, not blanket adjacency.
206+
if n := strings.Count(out, "\x1b[0m"+bg); n < 2 {
207+
t.Errorf("card re-asserts surface %d times, want >= 2:\n%q", n, out)
208+
}
209+
if !strings.Contains(plain(out), "span1 span2 tail") {
210+
t.Errorf("weave lost answer text:\n%s", plain(out))
211+
}
212+
}
213+
167214
// TestAnswerTextIsThemeBright pins the answer card's text color: glamour's
168215
// stock dark preset paints body text ANSI-252 gray, which reads dimmed on
169216
// the surface card. Every theme's answer body must paint the palette's

‎internal/tui/styles.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,37 @@ func answerGlamourStyle() ansi.StyleConfig {
173173
return cfg
174174
}
175175

176+
// surfaceSGR extracts the background escape a style actually paints, by
177+
// rendering a probe cell and reading it back. Deriving the sequence from
178+
// the style itself (instead of formatting the hex) keeps it consistent
179+
// with lipgloss's active color profile; "" when the style paints none.
180+
func surfaceSGR(st lipgloss.Style) string {
181+
out := st.Render(" ")
182+
i := strings.Index(out, "\x1b[48")
183+
if i < 0 {
184+
return ""
185+
}
186+
j := strings.IndexByte(out[i:], 'm')
187+
if j < 0 {
188+
return ""
189+
}
190+
return out[i : i+j+1]
191+
}
192+
193+
// weaveSurface re-asserts the card background after every embedded SGR
194+
// reset in glamour output. Glamour resets styling after each span and
195+
// paints its margin fillers with parent styles, so span gaps and margin
196+
// spaces otherwise fall back to the terminal's own background — the card
197+
// breaks up into padding-only blue with the text sitting on bare
198+
// terminal. Re-asserting after each reset keeps the surface continuous
199+
// under every span, whatever block produced it.
200+
func weaveSurface(s, bgSeq string) string {
201+
if bgSeq == "" {
202+
return s
203+
}
204+
return strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+bgSeq)
205+
}
206+
176207
// Layout — fixed heights for the chrome around the scrollable transcript.
177208
const (
178209
headerHeight = 2 // cockpit bar + hairline rule

‎internal/tui/view.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,11 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st
503503
if th.answerCard.GetBackground() == nil {
504504
out.WriteString(content)
505505
} else {
506-
out.WriteString(th.answerCard.Width(m.vp.Width - 2).Render(content))
506+
// Glamour resets styling after each span; without re-asserting
507+
// the surface after every reset, the text would sit on the
508+
// terminal's own background instead of the card.
509+
card := th.answerCard.Width(m.vp.Width - 2)
510+
out.WriteString(card.Render(weaveSurface(content, surfaceSGR(th.answerCard))))
507511
}
508512
}
509513
return out.String(), refs

0 commit comments

Comments
 (0)