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:
@@ -97,8 +97,11 @@ function sgr(code: number, body: string): string {
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
|
||||
* height cap collapses the middle, and the trailing `[exit code: N]` marker the
|
||||
* bash tool appends, from which the exit pill is recovered.
|
||||
* height cap collapses the middle. The exit status is authored separately in
|
||||
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
|
||||
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
|
||||
* terminal card shows the exit as its own pill and leaving the marker in would
|
||||
* render it twice (packages/bash/tool-bash/src/render.ts).
|
||||
*/
|
||||
const TERMINAL_OUTPUT_FIXTURE = [
|
||||
sgr(1, 'Running 4 checks'),
|
||||
@@ -122,7 +125,6 @@ const TERMINAL_OUTPUT_FIXTURE = [
|
||||
'markdown/Markdown.tsx 100% 100% 100% -',
|
||||
'',
|
||||
sgr(31, '1 of 4 checks failed'),
|
||||
'[exit code: 1]',
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user