From 594c4037d592fecb3de42651515d94e8b2c2d7c6 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 07:57:39 +0000 Subject: [PATCH] fix(vterm): track SGR per cell so re-attach replays colors The emulator stored bare runes and discarded SGR by design, so the replay a re-attaching client receives (Redraw) repainted the screen black and white; the follow-up resize nudge only rescues full-screen TUIs that fully repaint on SIGWINCH - inline agents (claude, omp) never repaint their history, so everything stayed monochrome until new output arrived. Track attributes per cell and replay them: - cell = rune + attr (16/256/truecolor fg+bg, bold/dim/italic/ underline/blink/reverse/strike), stamped from the terminal's current SGR state - a real SGR parser, including colon sub-parameter forms (38:5:196, 38:2::r:g:b, 4:0) and a 58 underline-color skip - back-color-erase: erases, scroll-revealed rows, and inserted blanks fill with the current background so colored bars replay intact; resize keeps bg-colored blank rows the same way Redraw paints them - Redraw repaints each cell with its original attributes (reset prefix, per-run transitions, reset before the cursor park) Capture/peek stays plain text by contract, and history stays plain rowText - scrollback replays uncolored; only the visible screen carries attributes. --- internal/vterm/sgr.go | 232 +++++++++++++++++++++++++++++++++++ internal/vterm/vterm.go | 159 +++++++++++++++--------- internal/vterm/vterm_test.go | 100 ++++++++++++++- 3 files changed, 432 insertions(+), 59 deletions(-) create mode 100644 internal/vterm/sgr.go diff --git a/internal/vterm/sgr.go b/internal/vterm/sgr.go new file mode 100644 index 0000000..8cbe433 --- /dev/null +++ b/internal/vterm/sgr.go @@ -0,0 +1,232 @@ +package vterm + +import ( + "strconv" + "strings" +) + +// col is a packed terminal color. The zero value is the default color; +// otherwise the top byte is the kind and the low bytes hold the value. +type col uint32 + +const ( + colKindIdx col = 1 << 24 // low byte: palette index 0–255 + colKindRGB col = 2 << 24 // low three bytes: r, g, b +) + +func idxColor(i int) col { return colKindIdx | col(clamp(i, 0, 255)) } // #nosec G115 -- clamped + +func rgbColor(r, g, b int) col { + return colKindRGB | col(clamp(r, 0, 255))<<16 | col(clamp(g, 0, 255))<<8 | col(clamp(b, 0, 255)) // #nosec G115 -- clamped +} + +// attr flag bits. +const ( + aBold uint8 = 1 << iota + aDim + aItalic + aUnderline + aBlink + aReverse + aStrike +) + +// attr is the SGR state of one cell. The zero value is "no attributes". +type attr struct { + fg, bg col + flags uint8 +} + +// cell is one grid position: a rune (0 = blank or wide-rune continuation) +// and the SGR attributes it was written or erased with. +type cell struct { + r rune + a attr +} + +// visible reports whether the cell contributes to a repaint even without a +// glyph: a colored background, or reverse/underline/strike, paint on blanks. +func (c cell) visible() bool { + if c.r != 0 && c.r != ' ' { + return true + } + return c.a.bg != 0 || c.a.flags&(aReverse|aUnderline|aStrike) != 0 +} + +func rowBlank(row []cell) bool { + for _, c := range row { + if c.visible() { + return false + } + } + return true +} + +// applySGR folds one SGR parameter string into the terminal's current +// attributes. Both classic semicolon parameters ("38;5;196") and colon +// sub-parameter forms ("38:5:196", "38:2::r:g:b", "4:0") are handled. +func (t *Terminal) applySGR(params string) { + if params == "" { + t.cur = attr{} + return + } + fields := strings.Split(params, ";") + for i := 0; i < len(fields); i++ { + sub := strings.Split(fields[i], ":") + switch code := atoiSGR(sub[0]); code { + case 0: + t.cur = attr{} + case 1: + t.cur.flags |= aBold + case 2: + t.cur.flags |= aDim + case 3: + t.cur.flags |= aItalic + case 4: + // 4:0 is "underline off"; bare 4 and the 4:n styles all underline. + if len(sub) > 1 && atoiSGR(sub[1]) == 0 { + t.cur.flags &^= aUnderline + } else { + t.cur.flags |= aUnderline + } + case 5, 6: + t.cur.flags |= aBlink + case 7: + t.cur.flags |= aReverse + case 9: + t.cur.flags |= aStrike + case 21: + t.cur.flags |= aUnderline // double underline: render as underline + case 22: + t.cur.flags &^= aBold | aDim + case 23: + t.cur.flags &^= aItalic + case 24: + t.cur.flags &^= aUnderline + case 25: + t.cur.flags &^= aBlink + case 27: + t.cur.flags &^= aReverse + case 29: + t.cur.flags &^= aStrike + case 30, 31, 32, 33, 34, 35, 36, 37: + t.cur.fg = idxColor(code - 30) + case 38: + t.cur.fg = parseExtColor(sub, fields, &i, t.cur.fg) + case 39: + t.cur.fg = 0 + case 40, 41, 42, 43, 44, 45, 46, 47: + t.cur.bg = idxColor(code - 40) + case 48: + t.cur.bg = parseExtColor(sub, fields, &i, t.cur.bg) + case 49: + t.cur.bg = 0 + case 58: + // Underline color: parse and discard so its arguments are not + // misread as standalone codes. + parseExtColor(sub, fields, &i, 0) + case 90, 91, 92, 93, 94, 95, 96, 97: + t.cur.fg = idxColor(code - 90 + 8) + case 100, 101, 102, 103, 104, 105, 106, 107: + t.cur.bg = idxColor(code - 100 + 8) + } + } +} + +// parseExtColor decodes the 38/48/58 extended-color forms. In the semicolon +// form the arguments are the following fields, which it consumes via i; in +// the colon form everything lives in sub. Unknown forms return prev. +func parseExtColor(sub, fields []string, i *int, prev col) col { + if len(sub) > 1 { + switch atoiSGR(sub[1]) { + case 5: + if len(sub) >= 3 { + return idxColor(atoiSGR(sub[2])) + } + case 2: + // 38:2:r:g:b, or 38:2::r:g:b — take the last three. + if len(sub) >= 5 { + return rgbColor(atoiSGR(sub[len(sub)-3]), atoiSGR(sub[len(sub)-2]), atoiSGR(sub[len(sub)-1])) + } + } + return prev + } + if *i+1 >= len(fields) { + return prev + } + switch atoiSGR(fields[*i+1]) { + case 5: + if *i+2 < len(fields) { + v := idxColor(atoiSGR(fields[*i+2])) + *i += 2 + return v + } + *i = len(fields) + case 2: + if *i+4 < len(fields) { + v := rgbColor(atoiSGR(fields[*i+2]), atoiSGR(fields[*i+3]), atoiSGR(fields[*i+4])) + *i += 4 + return v + } + *i = len(fields) + default: + *i++ + } + return prev +} + +// atoiSGR parses the leading digits of one SGR parameter; anything malformed +// truncates rather than failing, mirroring csiParams. +func atoiSGR(s string) int { + v := 0 + for _, c := range s { + if c < '0' || c > '9' || v > 1<<20 { + return v + } + v = v*10 + int(c-'0') + } + return v +} + +// sgr renders the attribute set as one escape sequence, always starting from +// a reset so the previous run's state cannot bleed through. +func (a attr) sgr() string { + var b strings.Builder + b.WriteString("\x1b[0") + for _, f := range []struct { + bit uint8 + code string + }{ + {aBold, "1"}, {aDim, "2"}, {aItalic, "3"}, {aUnderline, "4"}, + {aBlink, "5"}, {aReverse, "7"}, {aStrike, "9"}, + } { + if a.flags&f.bit != 0 { + b.WriteByte(';') + b.WriteString(f.code) + } + } + writeColor(&b, a.fg, 30, 90, "38") + writeColor(&b, a.bg, 40, 100, "48") + b.WriteByte('m') + return b.String() +} + +func writeColor(b *strings.Builder, c col, base, brightBase int, ext string) { + switch c >> 24 { + case 1: + i := int(c & 0xff) + switch { + case i < 8: + b.WriteString(";" + strconv.Itoa(base+i)) + case i < 16: + b.WriteString(";" + strconv.Itoa(brightBase+i-8)) + default: + b.WriteString(";" + ext + ";5;" + strconv.Itoa(i)) + } + case 2: + b.WriteString(";" + ext + ";2;" + + strconv.Itoa(int(c>>16&0xff)) + ";" + + strconv.Itoa(int(c>>8&0xff)) + ";" + + strconv.Itoa(int(c&0xff))) + } +} diff --git a/internal/vterm/vterm.go b/internal/vterm/vterm.go index 19ea23b..2781c2d 100644 --- a/internal/vterm/vterm.go +++ b/internal/vterm/vterm.go @@ -4,8 +4,9 @@ // plain-text tail the way `tmux capture-pane -p -J` used to. It models only // what peek/capture needs — a character grid, scrollback history, cursor // motion, erase/insert/delete, scroll regions, and the alternate screen. -// Colors and attributes (SGR) are parsed and discarded: capture output is -// plain text by contract, exactly like the old capture-pane path. +// Colors and attributes (SGR) are tracked per cell so Redraw can repaint a +// re-attaching client faithfully; Capture output stays plain text by +// contract, exactly like the old capture-pane path. package vterm import ( @@ -38,6 +39,9 @@ type Terminal struct { state parseState seq []byte partial []byte + + // cur is the current SGR state; printed cells and BCE fills carry it. + cur attr } // bufLine is one captured line: its text and whether it is the soft-wrap @@ -273,17 +277,17 @@ func (t *Terminal) dispatchCSI(params string, final byte) { case 'J': t.eraseDisplay(arg(0, 0) /* default 0 even when params empty */) case 'K': - s.eraseLine(argDefault(n, 0, 0), t.cols) + s.eraseLine(argDefault(n, 0, 0), t.cols, t.bceFill()) case 'L': - s.insertLines(arg(0, 1)) + s.insertLines(arg(0, 1), t.bceFill()) case 'M': - s.deleteLines(arg(0, 1)) + s.deleteLines(arg(0, 1), t.bceFill()) case '@': - s.insertChars(arg(0, 1), t.cols) + s.insertChars(arg(0, 1), t.cols, t.bceFill()) case 'P': - s.deleteChars(arg(0, 1), t.cols) + s.deleteChars(arg(0, 1), t.cols, t.bceFill()) case 'X': - s.eraseChars(arg(0, 1), t.cols) + s.eraseChars(arg(0, 1), t.cols, t.bceFill()) case 'S': for i := 0; i < arg(0, 1); i++ { t.scrollUp() @@ -315,11 +319,21 @@ func (t *Terminal) dispatchCSI(params string, final byte) { case 'u': s.x, s.y = min(s.savedX, t.cols-1), min(s.savedY, t.rows-1) s.pendingWrap = false - case 'm', 'n', 'q', 't', 'g', 'c': - // SGR, reports, cursor style, window ops, tab clears: no grid effect. + case 'm': + if !private { + t.applySGR(params) + } + case 'n', 'q', 't', 'g', 'c': + // Reports, cursor style, window ops, tab clears: no grid effect. } } +// bceFill is the cell erases reveal: blank, carrying the current background +// (xterm's back-color-erase), so colored bars and panels replay intact. +func (t *Terminal) bceFill() cell { + return cell{a: attr{bg: t.cur.bg}} +} + // argDefault is arg() but distinguishing "no parameter" from explicit 0 is // unnecessary for our erase handling; it simply mirrors arg with a 0 default. func argDefault(n []int, i, def int) int { @@ -367,7 +381,7 @@ func (t *Terminal) switchAlt(on bool) { if on { s := t.main s.savedX, s.savedY = s.x, s.y - t.alt.clearAll() + t.alt.clearAll(t.bceFill()) t.alt.x, t.alt.y = 0, 0 t.onAlt = true return @@ -394,9 +408,9 @@ func (t *Terminal) print(r rune) { s.x = 0 t.lineFeed(true) } - s.cells[s.y][s.x] = r + s.cells[s.y][s.x] = cell{r: r, a: t.cur} if w == 2 && s.x+1 < t.cols { - s.cells[s.y][s.x+1] = 0 + s.cells[s.y][s.x+1] = cell{a: t.cur} } s.x += w if s.x >= t.cols { @@ -432,7 +446,7 @@ func (t *Terminal) scrollUp() { copy(s.cells[top:bot], s.cells[top+1:bot+1]) copy(s.wrapped[top:bot], s.wrapped[top+1:bot+1]) s.cells[bot] = rec - clearRow(s.cells[bot]) + clearRow(s.cells[bot], t.bceFill()) s.wrapped[bot] = false } @@ -443,7 +457,7 @@ func (t *Terminal) scrollDownRegion() { copy(s.cells[top+1:bot+1], s.cells[top:bot]) copy(s.wrapped[top+1:bot+1], s.wrapped[top:bot]) s.cells[top] = rec - clearRow(s.cells[top]) + clearRow(s.cells[top], t.bceFill()) s.wrapped[top] = false } @@ -469,21 +483,22 @@ func (t *Terminal) pushHistory(l bufLine) { func (t *Terminal) eraseDisplay(mode int) { s := t.active() + fill := t.bceFill() switch mode { case 0: - s.eraseLine(0, t.cols) + s.eraseLine(0, t.cols, fill) for y := s.y + 1; y < t.rows; y++ { - clearRow(s.cells[y]) + clearRow(s.cells[y], fill) s.wrapped[y] = false } case 1: - s.eraseLine(1, t.cols) + s.eraseLine(1, t.cols, fill) for y := 0; y < s.y; y++ { - clearRow(s.cells[y]) + clearRow(s.cells[y], fill) s.wrapped[y] = false } case 2, 3: - s.clearAll() + s.clearAll(fill) } } @@ -492,6 +507,7 @@ func (t *Terminal) reset() { t.main = newScreen(t.cols, t.rows) t.alt = newScreen(t.cols, t.rows) t.state = stGround + t.cur = attr{} } // Resize changes the grid size, preserving as much content as fits. Scroll @@ -553,25 +569,51 @@ func (t *Terminal) Capture(maxLines int) string { } // Redraw returns an ANSI byte sequence that repaints the current screen on a -// fresh terminal: clear, draw every row, and park the cursor. The session host -// sends it to a newly attached client so the user sees the live screen -// immediately (a follow-up resize nudge makes full-screen TUIs repaint with -// their own colors). +// fresh terminal: reset attributes, clear, draw every row with the SGR state +// each cell was written with, and park the cursor. The session host sends it +// to a newly attached client so the user sees the live screen — colors +// included — immediately. func (t *Terminal) Redraw() []byte { s := t.active() var b strings.Builder - b.WriteString("\x1b[2J\x1b[H") + b.WriteString("\x1b[0m\x1b[2J\x1b[H") last := t.rows - 1 for ; last >= 0; last-- { - if rowText(s.cells[last]) != "" { + if !rowBlank(s.cells[last]) { break } } + cur := attr{} for y := 0; y <= last; y++ { if y > 0 { b.WriteString("\r\n") } - b.WriteString(rowText(s.cells[y])) + row := s.cells[y] + end := len(row) - 1 + for ; end >= 0; end-- { + if row[end].visible() { + break + } + } + for x := 0; x <= end; x++ { + c := row[x] + if c.r == 0 && x > 0 && runewidth.RuneWidth(row[x-1].r) == 2 { + // Wide-rune continuation: no extra column. + continue + } + if c.a != cur { + b.WriteString(c.a.sgr()) + cur = c.a + } + r := c.r + if r == 0 { + r = ' ' + } + b.WriteRune(r) + } + } + if cur != (attr{}) { + b.WriteString("\x1b[0m") } b.WriteString("\x1b[" + strconv.Itoa(s.y+1) + ";" + strconv.Itoa(s.x+1) + "H") return []byte(b.String()) @@ -579,7 +621,7 @@ func (t *Terminal) Redraw() []byte { // screen is one character grid (main or alternate). type screen struct { - cells [][]rune + cells [][]cell wrapped []bool x, y int // pendingWrap defers the wrap after writing the last column (DECAWM @@ -590,16 +632,16 @@ type screen struct { } func newScreen(cols, rows int) *screen { - s := &screen{cells: make([][]rune, rows), wrapped: make([]bool, rows), bottom: rows - 1} + s := &screen{cells: make([][]cell, rows), wrapped: make([]bool, rows), bottom: rows - 1} for i := range s.cells { - s.cells[i] = make([]rune, cols) + s.cells[i] = make([]cell, cols) } return s } -func (s *screen) clearAll() { +func (s *screen) clearAll(fill cell) { for y := range s.cells { - clearRow(s.cells[y]) + clearRow(s.cells[y], fill) s.wrapped[y] = false } } @@ -614,23 +656,23 @@ func (s *screen) moveY(d int) { s.pendingWrap = false } -func (s *screen) eraseLine(mode, cols int) { +func (s *screen) eraseLine(mode, cols int, fill cell) { row := s.cells[s.y] switch mode { case 0: for x := s.x; x < cols; x++ { - row[x] = 0 + row[x] = fill } case 1: for x := 0; x <= s.x && x < cols; x++ { - row[x] = 0 + row[x] = fill } case 2: - clearRow(row) + clearRow(row, fill) } } -func (s *screen) insertLines(n int) { +func (s *screen) insertLines(n int, fill cell) { if s.y < s.top || s.y > s.bottom { return } @@ -639,12 +681,12 @@ func (s *screen) insertLines(n int) { copy(s.cells[s.y+1:s.bottom+1], s.cells[s.y:s.bottom]) copy(s.wrapped[s.y+1:s.bottom+1], s.wrapped[s.y:s.bottom]) s.cells[s.y] = rec - clearRow(s.cells[s.y]) + clearRow(s.cells[s.y], fill) s.wrapped[s.y] = false } } -func (s *screen) deleteLines(n int) { +func (s *screen) deleteLines(n int, fill cell) { if s.y < s.top || s.y > s.bottom { return } @@ -653,31 +695,31 @@ func (s *screen) deleteLines(n int) { copy(s.cells[s.y:s.bottom], s.cells[s.y+1:s.bottom+1]) copy(s.wrapped[s.y:s.bottom], s.wrapped[s.y+1:s.bottom+1]) s.cells[s.bottom] = rec - clearRow(s.cells[s.bottom]) + clearRow(s.cells[s.bottom], fill) s.wrapped[s.bottom] = false } } -func (s *screen) insertChars(n, cols int) { +func (s *screen) insertChars(n, cols int, fill cell) { row := s.cells[s.y] for i := 0; i < n; i++ { copy(row[s.x+1:cols], row[s.x:cols-1]) - row[s.x] = 0 + row[s.x] = fill } } -func (s *screen) deleteChars(n, cols int) { +func (s *screen) deleteChars(n, cols int, fill cell) { row := s.cells[s.y] for i := 0; i < n; i++ { copy(row[s.x:cols-1], row[s.x+1:cols]) - row[cols-1] = 0 + row[cols-1] = fill } } -func (s *screen) eraseChars(n, cols int) { +func (s *screen) eraseChars(n, cols int, fill cell) { row := s.cells[s.y] for x := s.x; x < s.x+n && x < cols; x++ { - row[x] = 0 + row[x] = fill } } @@ -687,7 +729,9 @@ func (s *screen) resize(cols, rows, oldCols, oldRows int, pushTop bool, t *Termi if rows < oldRows { drop := oldRows - rows // Keep the cursor visible: drop blank rows from the bottom first. - for drop > 0 && oldRows-1 > s.y && rowText(s.cells[oldRows-1]) == "" { + // rowBlank (not rowText) so a BCE-colored blank row survives the + // shrink the same way Redraw would paint it. + for drop > 0 && oldRows-1 > s.y && rowBlank(s.cells[oldRows-1]) { s.cells = s.cells[:oldRows-1] s.wrapped = s.wrapped[:oldRows-1] oldRows-- @@ -705,13 +749,13 @@ func (s *screen) resize(cols, rows, oldCols, oldRows int, pushTop bool, t *Termi } } for len(s.cells) < rows { - s.cells = append(s.cells, make([]rune, cols)) + s.cells = append(s.cells, make([]cell, cols)) s.wrapped = append(s.wrapped, false) } for i := range s.cells { row := s.cells[i] if len(row) < cols { - grown := make([]rune, cols) + grown := make([]cell, cols) copy(grown, row) s.cells[i] = grown } else if len(row) > cols { @@ -726,18 +770,19 @@ func (s *screen) resize(cols, rows, oldCols, oldRows int, pushTop bool, t *Termi s.savedY = clamp(s.savedY, 0, rows-1) } -func clearRow(row []rune) { +func clearRow(row []cell, fill cell) { for i := range row { - row[i] = 0 + row[i] = fill } } -// rowText renders a grid row as a string: zero cells inside the line become -// spaces, trailing blanks are trimmed. -func rowText(row []rune) string { +// rowText renders a grid row as plain text: zero cells inside the line become +// spaces, trailing blanks are trimmed, attributes are ignored (the Capture +// contract). +func rowText(row []cell) string { last := len(row) - 1 for ; last >= 0; last-- { - if row[last] != 0 && row[last] != ' ' { + if row[last].r != 0 && row[last].r != ' ' { break } } @@ -746,11 +791,11 @@ func rowText(row []rune) string { } var b strings.Builder for x := 0; x <= last; x++ { - r := row[x] + r := row[x].r if r == 0 { // A zero cell is either an erased cell or a wide-rune // continuation; the continuation contributes no extra column. - if x > 0 && runewidth.RuneWidth(row[x-1]) == 2 { + if x > 0 && runewidth.RuneWidth(row[x-1].r) == 2 { continue } r = ' ' diff --git a/internal/vterm/vterm_test.go b/internal/vterm/vterm_test.go index 24665fc..4984b5a 100644 --- a/internal/vterm/vterm_test.go +++ b/internal/vterm/vterm_test.go @@ -199,8 +199,8 @@ func TestRedrawPaintsScreenAndCursor(t *testing.T) { term := New(20, 5, 0) feed(t, term, "row1\r\nrow2") out := string(term.Redraw()) - if !strings.HasPrefix(out, "\x1b[2J\x1b[H") { - t.Fatalf("redraw must clear first: %q", out) + if !strings.HasPrefix(out, "\x1b[0m\x1b[2J\x1b[H") { + t.Fatalf("redraw must reset attributes and clear first: %q", out) } if !strings.Contains(out, "row1\r\nrow2") { t.Fatalf("redraw missing content: %q", out) @@ -348,3 +348,99 @@ func TestScrollRegionReverseWrapAndKeypadIgnored(t *testing.T) { t.Fatalf("ignored escapes: %q", got) } } + +// Re-attach repaints the screen from the emulator grid, so the grid must +// remember SGR attributes and Redraw must re-emit them — otherwise every +// re-attach turns the session black and white until the agent happens to +// repaint. Capture stays plain text by contract. +func TestRedrawReplaysColors(t *testing.T) { + term := New(60, 5, 0) + feed(t, term, "\x1b[1;32mgreen\x1b[0m plain \x1b[38;5;196mred256\x1b[0m \x1b[48;2;10;20;30mtruebg\x1b[0m") + out := string(term.Redraw()) + for _, want := range []string{ + "\x1b[0;1;32mgreen", // bold + 16-color fg + "\x1b[0m plain ", // explicit reset between styled runs + "\x1b[0;38;5;196mred256", // 256-color fg + "\x1b[0;48;2;10;20;30mtruebg", // truecolor bg + "truebg\x1b[0m\x1b[", // attributes reset before the cursor park + } { + if !strings.Contains(out, want) { + t.Fatalf("redraw missing %q: %q", want, out) + } + } + if got := term.Capture(10); strings.Contains(got, "\x1b") { + t.Fatalf("capture must stay plain text: %q", got) + } +} + +// Bright (90–107) and basic background colors round-trip through the grid. +func TestRedrawReplaysBrightAndBackgroundColors(t *testing.T) { + term := New(40, 3, 0) + feed(t, term, "\x1b[93;44mwarn\x1b[0m") + out := string(term.Redraw()) + if !strings.Contains(out, "\x1b[0;93;44mwarn") { + t.Fatalf("bright fg + bg lost: %q", out) + } +} + +// SGR attribute-off codes clear only their attribute. +func TestSGRAttributeOffCodes(t *testing.T) { + term := New(40, 3, 0) + feed(t, term, "\x1b[1;4;31mab\x1b[22;24mcd") + out := string(term.Redraw()) + if !strings.Contains(out, "\x1b[0;1;4;31mab") { + t.Fatalf("bold+underline+red lost: %q", out) + } + if !strings.Contains(out, "\x1b[0;31mcd") { + t.Fatalf("22/24 must clear bold/underline but keep color: %q", out) + } +} + +// Colon-separated SGR sub-parameters (kitty/newer emitters) parse the same as +// the semicolon forms. +func TestSGRColonSubparameters(t *testing.T) { + term := New(40, 3, 0) + feed(t, term, "\x1b[38:5:99mX\x1b[0m \x1b[38:2::1:2:3mY\x1b[0m \x1b[4:0mZ") + out := string(term.Redraw()) + if !strings.Contains(out, "\x1b[0;38;5;99mX") { + t.Fatalf("colon 256-color form lost: %q", out) + } + if !strings.Contains(out, "\x1b[0;38;2;1;2;3mY") { + t.Fatalf("colon truecolor form lost: %q", out) + } + if strings.Contains(out, "\x1b[0;4mZ") { + t.Fatalf("4:0 must mean underline off: %q", out) + } +} + +// Erases fill with the current background (BCE), so a TUI that paints colored +// bars via SGR-bg + erase replays with its background intact. +func TestRedrawPreservesBackgroundColorErase(t *testing.T) { + term := New(10, 3, 0) + feed(t, term, "\x1b[44m\x1b[2J\x1b[Hab") + out := string(term.Redraw()) + if !strings.Contains(out, "\x1b[0;44mab") { + t.Fatalf("text on erased bg lost its background: %q", out) + } + // The erased remainder of the row paints as spaces in the same bg run + // (no attribute switch), and bg-only rows below are not trimmed. + if !strings.Contains(out, "ab"+strings.Repeat(" ", 8)+"\r\n") { + t.Fatalf("erased cells must replay as bg-colored spaces: %q", out) + } + if rows := strings.Count(out, "\r\n") + 1; rows != 3 { + t.Fatalf("bg-colored blank rows must not be trimmed, got %d rows: %q", rows, out) + } + if got := term.Capture(10); strings.Contains(got, "\x1b") { + t.Fatalf("capture must stay plain text: %q", got) + } +} + +// EL with a background color extends that background to the end of the line. +func TestEraseLineUsesCurrentBackground(t *testing.T) { + term := New(8, 2, 0) + feed(t, term, "xy\x1b[42m\x1b[K") + out := string(term.Redraw()) + if !strings.Contains(out, "xy\x1b[0;42m"+strings.Repeat(" ", 6)) { + t.Fatalf("EL must fill with current bg: %q", out) + } +}