fix(web): bound the SGR state and follow real terminal widths

Nine findings, one critical. Terminal cases verified in a real terminal first.

CRITICAL: cells held the accumulated SGR history, so every state boundary
re-emitted the whole chain — output switching color without a full reset
emitted O(n^2) characters. Measured: 3200 such cells produced 25 MB, and the
reviewer's ~90 KB alternating-color case is well under bash's own output cap.
State is now a normalized record (foreground, background, attribute set) with
one canonical sequence per boundary, so the emitted text is linear in cells;
the 90 KB case parses in 36 ms. That also makes the attribute closers every
chalk-based tool writes actually close: `\x1b[1mbold\x1b[22mplain` leaves the
following write PLAIN, which a real terminal confirms.

Width follows emoji presentation, not the U+2600-U+27BF block: `A✓B` redrawn
with `XY` shows `XYB`, so the check every progress line writes is ONE column.
Taking the block as wide misaligned exactly the output this card exists for.

Writing over either half of a wide pair blanks the other, since a terminal
cannot leave one cell of a two-cell glyph standing.

`line\n\x1b[0m` does not end in a newline as a string yet its last parsed line
holds nothing visible, so the terminator check now reads the parsed lines — it
had added a blank row and inflated the collapse count.

A line with no cursor movement no longer builds a column buffer at all; only
its SGR is folded, so an `ls -R` or a 5k-line log allocates nothing per
character.

The `.terminalDescription` rule had been inserted into an existing grouped
selector, silently giving `.codeBody` description typography and changing its
bottom margin from 4px to 0 — a pre-existing surface this PR does not own.
Split out, `.codeBody`'s margin restored.

Three comments contradicted their code: the fixture's exit-marker line (still
claiming recovery from a marker deliberately removed), `bash-sample`'s header
(still routing a click to the details panel, and calling the consumer's cap the
block's own), and a DetailsPanel comment stacked above the wrong rule. The
ui-primitives README documented only the CR/BS half of the replay, so a reader
would expect `OK0%` where `100%\r\x1b[KOK` renders `OK`.
This commit is contained in:
Chinesezjc
2026-07-29 16:42:10 +08:00
parent 4145cd7075
commit fdcb45b619
14 changed files with 274 additions and 66 deletions

View File

@@ -357,6 +357,75 @@ describe('parseAnsiLines: line-end state and column widths', () => {
})
})
describe('parseAnsiLines: bounded state and true widths', () => {
it('emits one canonical sequence per boundary however the state was reached', () => {
// Colors that never fully reset used to accumulate raw sequence history per
// cell, so every boundary re-emitted the whole chain: 3200 such cells
// produced 25 MB and eventually a RangeError. The state is normalized now,
// so the emitted text stays linear in the number of cells.
let input = ''
for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
})
it('closes an attribute with its closer instead of appending to the state', () => {
// `1` then `22` is bold then not-bold, which every chalk-based tool writes;
// appending both left the cell bold and grew the chain.
// Verified in a real terminal: the `22` closes the bold, so the `x` written
// after the redraw is PLAIN. Appending both left it bold and grew the chain.
expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
{ text: 'x', style: undefined },
]])
expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
{ text: 'A', style: { fontWeight: 700 } },
{ text: 'B', style: undefined },
]])
})
it('folds extended colors, backgrounds and every attribute closer', () => {
// The 256-palette and truecolor forms consume their own arguments, so the
// fold has to take them whole rather than as separate codes.
expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'rgb(255, 135, 0)' } },
]])
expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'rgb(10, 20, 30)' } },
]])
// A background survives the same way, and `49` closes it.
expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: undefined },
]])
// Each closer drops only its own attribute: `4` underline closed by `24`
// while the italic opened before it stays in force.
expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: { fontStyle: 'italic' } },
]])
// `39` closes a foreground without touching the background.
expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
]])
})
it('treats a text-presentation symbol as one column', () => {
// Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
// check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
// misaligned exactly the progress output this card exists to show.
expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
// An emoji-presentation character is two, so the same redraw leaves a blank.
expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
})
it('blanks both halves of a wide pair when either is overwritten', () => {
// A terminal cannot leave one cell of a two-cell glyph standing, so writing
// over the spacer clears the lead as well.
// Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
// the second glyph — writing the lead cell blanks its spacer, so the column
// stays occupied rather than collapsing.
expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
})
})
describe('parseAnsiLines: SGR across lines', () => {
it('carries active state past a newline, as a terminal does', () => {
// Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.

View File

@@ -146,6 +146,15 @@ describe('TerminalBlock states', () => {
expect(outputLines(view.container)).toEqual(['a', 'b'])
})
it('drops the output terminator even when a reset follows the final newline', () => {
// `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed
// line holds nothing visible — a common shape, since tools close their color
// after the last line. Judging the terminator on the raw text added a blank
// row and inflated both the card height and the collapse count.
const view = render(<TerminalBlock command="ls" output={`a\nb\n${ESC}[0m`} />)
expect(outputLines(view.container)).toEqual(['a', 'b'])
})
it('keeps a genuinely blank final line when the output ends with two newlines', () => {
const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
expect(outputLines(view.container)).toEqual(['a', 'b', ''])