fix(web): model erase-in-line, tab stops, and cross-line SGR

Six findings. Each terminal-semantics case was checked in a real terminal
(tmux, reading back the painted screen) before changing anything:

`100%\r\x1b[KOK` shows `OK`. Modelling the `\r` without its erase left the
previous frame's tail standing — a regression against the old truncate, since
`\r\x1b[K` is the single idiom every spinner and progress bar writes. Erase is
now part of the same replay, in all three parameter forms.

`a\tb\rXY` shows `XY      b`. Counting a tab as one column produced `XYb` and
destroyed the alignment this card exists to hold, so the cursor now advances
by terminal columns: tabs reach the next 8-column stop and a wide character
takes two cells.

`\x1b[31mabc\rX\nnext` paints BOTH lines red. A newline does not reset the
graphic state, so state threads from one replayed line to the next instead of
closing at each line end.

Only `m` accumulates into a cell's style now. Folding cursor and erase
sequences in grew the state string per redraw and emitted boundaries anser
had to discard.

The empty check reads the parsed lines the card renders rather than the raw
text: output that is only escapes or control bytes survives `trim()` yet
parses to nothing, and drew blank rows plus a copy control for invisible
bytes instead of the placeholder.

The gutter is the card's own left padding rather than a margin. Every render
site rewrites `margin` wholesale for its own indent, which silently cancelled
the reservation and let a container clip the dot.

The fixture sample no longer carries an `[exit code: 1]` line: the real bash
presenter consumes that marker precisely because the card shows the exit as
its own pill, so the built-bundle snapshot had pinned a frame showing it
twice — one the product path cannot produce.
This commit is contained in:
Chinesezjc
2026-07-29 15:36:16 +08:00
parent 612b3c7c4e
commit 43de478d10
11 changed files with 224 additions and 55 deletions

View File

@@ -249,6 +249,72 @@ describe('parseAnsiLines: backspaces', () => {
})
})
describe('parseAnsiLines: erase and column arithmetic', () => {
it('erases the rest of the line, the fixed companion of a redraw', () => {
// Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
// progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
// stands and the card shows text the terminal never displayed.
expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
// The parameterless form and `0` are the same erase.
expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
})
it('erases the whole line for the 2K form and to the cursor for 1K', () => {
expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
// 1K clears left of the cursor without moving it, so those columns read as
// blanks — verified in a real terminal, which shows ` |` for this input.
expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
})
it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
// 2K clears the line but leaves the cursor where it was, so writing there
// leaves the columns before it unwritten — blanks, as a terminal shows.
expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: ' x', style: undefined })
})
it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
// Verified in a real terminal: `a\tb\rXY` shows `XY b` — the `b` sits at
// column 8, which a two-character redraw cannot reach. Counting the tab as
// one column would have produced `XYb` and destroyed the alignment.
expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY b', style: undefined })
})
it('counts a wide character as the two columns a terminal advances', () => {
// `中` occupies two cells, so a two-character redraw covers exactly it.
expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
})
it('does not accumulate a cursor or erase sequence into a cell style', () => {
// Only SGR carries graphic state. An erase folded into the style string
// would grow it per redraw and emit boundaries anser has to discard.
expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
{ text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
]])
})
})
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.
// A newline does not reset the graphic state, so a replayed line must hand
// its state to the next one instead of closing it off.
expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
[{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
[{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
])
})
it('tracks state through a line that needs no replay', () => {
// The middle line has no movement, so it is not replayed — but its own SGR
// still has to reach the line after it.
expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
[{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
])
})
})
describe('parseAnsiLines: runs spanning lines', () => {
it('carries one run\'s style onto every line it covers', () => {
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([

View File

@@ -124,6 +124,17 @@ describe('TerminalBlock states', () => {
expect(screen.getByText('无输出')).toBeTruthy()
})
it('treats output that renders nothing visible as empty', () => {
// A lone reset, an OSC title, an erase: all survive `text.trim()` yet parse
// to nothing. Judging emptiness on the raw text drew a box of blank rows
// plus a copy control for invisible bytes, and hid the placeholder.
const view = render(<TerminalBlock command="true" output={`${ESC}[0m`} exitCode={0} />)
expect(view.getByText('无输出')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
view.rerender(<TerminalBlock command="true" output={`${ESC}]0;title${ESC}\\`} exitCode={0} />)
expect(view.getByText('无输出')).toBeTruthy()
})
it('merges className onto the wrapper', () => {
const view = render(<TerminalBlock command="ls" className="x" output="a" />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)