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

@@ -7,13 +7,18 @@
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* Reserved strip to the left of the card for the per-line run-state dots.
The dots sit outside the card surface, so a reader scans command state
down one column without the dots competing with the commands themselves. */
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
position: relative;
margin: 16px 0 16px var(--dsl-terminal-gutter);
margin: 16px 0;
/* The gutter is the card's OWN padding, not a margin: every consumer rewrites
`margin` wholesale (each render site sets its own indent), which silently
cancelled the reservation and let the dot fall outside the card into a
container that clips it. Owning the reservation here keeps the invariant
with the component that depends on it. */
padding-left: var(--dsl-terminal-gutter);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
@@ -25,7 +30,9 @@
display: flex;
align-items: flex-start;
gap: 12px;
padding: 9px 14px;
/* No left padding: the card's gutter already insets this row, and the banner
background still has to span the full surface. */
padding: 9px 14px 9px 0;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
@@ -50,12 +57,13 @@
line-height: var(--dsl-terminal-line-height);
}
/* Out of flow in the gutter, so a dot neither indents its command nor depends
on the command's own text metrics to line up with it. Centered against the
row's line box rather than sitting on the code font's baseline. */
/* Out of flow inside the card's own gutter padding, so the reservation and the
dot move together and no consumer margin can pull them apart; the dot neither
indents its command nor depends on the command's text metrics to line up.
Centered against the row's line box, not the code font's baseline. */
.runState {
position: absolute;
left: calc(-1 * var(--dsl-terminal-gutter));
left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
top: 50%;
transform: translateY(-50%);
}
@@ -103,7 +111,7 @@
}
.output {
padding: 12px 14px;
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
@@ -132,7 +140,7 @@
}
.empty {
padding: 12px 14px;
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -154,7 +154,12 @@ export function TerminalBlock({
const body = command.endsWith('\n') ? command.slice(0, -1) : command
return body.split('\n')
}, [command])
const empty = text.trim() === ''
// Read from the parsed lines the card actually renders, not from the raw text:
// output that is only escapes or control bytes (a lone reset, an OSC title, an
// erase) survives `text.trim()` yet parses to nothing visible. Judging it on
// the raw text drew an output box of blank rows plus a copy control for
// invisible bytes, and hid the placeholder that belongs there.
const empty = lines.every(line => line.every(span => span.text.trim() === ''))
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as the TUI transcript's collapsed tool card, so a

View File

@@ -87,6 +87,33 @@ const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/** Terminal tab stop width; a tab advances to the next multiple of this. */
const TAB_WIDTH = 8
/**
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
* CJK punctuation, and the emoji/symbol blocks a command's output realistically
* carries.
*/
const WIDE_CHAR = new RegExp(
'\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
+ '|[\\u{1f300}-\\u{1faff}\\u{2600}-\\u{27bf}\\uff01-\\uff60\\u3000-\\u303e]',
'u',
)
/**
* Whether a character occupies two terminal columns (CJK, fullwidth forms,
* emoji). Covers the ranges a command's output realistically carries; a
* narrower guess would misalign the columns this card exists to preserve.
* @param char - one character from the output.
* @returns true when the terminal advances two columns for it.
*/
function isWide(char: string): boolean {
const code = char.codePointAt(0)
if (code === undefined || code < 0x1100) return false
return WIDE_CHAR.test(char)
}
/**
* Replay one line's cursor movements the way a terminal paints it, into a
* column buffer. Carriage return and backspace only MOVE the cursor — neither
@@ -104,42 +131,70 @@ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
* @param line - one output line, still carrying its CSI sequences.
* @returns the line as the terminal would have it after every movement.
*/
function replayLine(line: string): string {
function replayLine(line: string, entrySgr: string): { text: string; sgr: string } {
// Same shape anser splits on, so a sequence is one unit here as well.
const csi = /\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*[\u0040-\u007e]/g
const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
/** Per column: the SGR state in force when it was written, and its character. */
const columns: { sgr: string; char: string }[] = []
const columns: ({ sgr: string; char: string } | undefined)[] = []
let cursor = 0
// SGR state accumulates as the line is scanned, exactly as a terminal tracks
// it: each cell is stamped with whatever was in force at the moment of the
// write, so a later redraw cannot restyle the cells it does not reach.
let sgr = ''
// write, so a later redraw cannot restyle the cells it does not reach. It
// enters carrying the previous line's state, since a newline does not reset it.
let sgr = entrySgr
let at = 0
const consume = (chunk: string): void => {
for (const char of chunk) {
if (char === '\r') { cursor = 0; continue }
if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
if (char === '\t') {
// A tab advances to the next 8-column stop, leaving the cells it skips
// as they were — which is how a redraw can leave a tabbed column
// standing. Column alignment is the whole point of this card.
const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
continue
}
columns[cursor] = { sgr, char }
cursor++
// A wide character occupies two columns; the trailing one is a spacer the
// terminal keeps blank, so a later write there cannot split the glyph.
if (isWide(char)) { columns[cursor] = { sgr, char: '' }; cursor++ }
}
}
for (const match of line.matchAll(csi)) {
consume(line.slice(at, match.index))
// A reset clears the accumulated state; anything else adds to it.
sgr = /^\u001b\[0?m$/.test(match[0]) ? '' : sgr + match[0]
at = match.index + match[0].length
// Both groups are mandatory in the pattern, so destructuring types them as
// strings without a fallback that could never run.
const params = String(match[1])
const final = String(match[2])
if (final === 'K') {
// Erase in line: the fixed companion of `\r` in every spinner and progress
// bar. Without it a shorter redraw leaves the previous frame's tail
// standing, which is text the terminal never showed. `1` blanks the
// columns before the cursor rather than dropping them, since the cursor
// does not move and a later write can still land past them.
if (params === '1') for (let index = 0; index < cursor; index++) columns[index] = { sgr, char: ' ' }
else columns.length = params === '2' ? 0 : cursor
continue
}
// Only SGR carries graphic state; every other final byte is a cursor or
// erase action that must not be accumulated into a cell's style.
if (final !== 'm') continue
sgr = /^0?$/.test(params) ? '' : sgr + match[0]
}
consume(line.slice(at))
// Re-emit the columns, opening a run only where its SGR state changes and
// closing the previous one, so anser sees the same styling a terminal shows.
// No index can be missing: `\r` and backspace only move the cursor LEFT, so
// every column up to the furthest write has been written at least once.
// Re-emit the columns, opening a run only where its SGR state changes, so
// anser sees the same styling a terminal shows. A `\x1b[2K` can leave holes
// before the cursor, which a terminal paints as blanks.
let out = ''
let active = ''
for (const column of columns) {
let active = entrySgr
for (const slot of columns) {
const column = slot ?? { sgr: '', char: ' ' }
if (column.sgr !== active) {
if (active !== '') out += '\u001b[0m'
out += column.sgr
@@ -147,21 +202,35 @@ function replayLine(line: string): string {
}
out += column.char
}
return active === '' ? out : `${out}\u001b[0m`
// The state at the line's end continues onto the next line, so it is returned
// rather than closed off with a reset here.
return { text: out, sgr: active }
}
/**
* Replay every line's cursor movements. A `\r` that only terminates a CRLF line
* is dropped first, so those lines keep their text instead of being redrawn onto
* themselves.
* themselves. SGR state threads across lines: a newline does not reset it, so a
* run opened before a redraw still colors the lines after it.
* @param text - output text, already free of OSC and non-CSI escapes.
* @returns the text with each line painted as the terminal would.
*/
function applyCursorMovements(text: string): string {
return text.split('\n')
.map(raw => raw.replace(/\r+$/, ''))
.map(line => (/[\r\u0008]/.test(line) ? replayLine(line) : line))
.join('\n')
const replayed: string[] = []
let sgr = ''
for (const raw of text.split('\n')) {
const line = raw.replace(/\r+$/, '')
// A line with no cursor movement or erase needs no replay — its tabs stay
// literal for `white-space: pre` to lay out — but its own SGR still has to
// be tracked so a later line that DOES replay enters with the right state.
// Tabs only need column arithmetic where a redraw can land on them, which is
// exactly the replayed case. An erase counts: `\x1b[1K` blanks columns even
// with no `\r` beside it.
const result = replayLine(line, sgr)
replayed.push(/\r|\u0008|\u001b\[[0-9]*K/.test(line) ? result.text : line)
sgr = result.sgr
}
return replayed.join('\n')
}
/**

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)