fix(web): close a run at the line end and count columns correctly

Eight findings, each terminal case verified in a real terminal first:

`\x1b[32mdone\rok\x1b[0m` then `plain` shows `okne` green and `plain` in the
DEFAULT color. The replay returned the last written cell's state, so a reset
landing after the final write vanished from both the text and the returned
state — and every build tool writes exactly that shape, so the color leaked
onto all later output. The replay now converges to the state the scan ended
in, which is also what it hands to the next line.

`abcd\b\x1b[1K|` shows `   |`: CSI 1K erases THROUGH the cursor column, and
the loop stopped before it. The erase mode also reads only the first
parameter now, since a terminal treats `1;2K` exactly as `1K`.

`éx\rYZ` shows `YZ`: a combining mark takes no column, so it attaches
to the cell already written instead of advancing the cursor and leaving the
`x` standing.

`中x\rA` shows `A x`: overwriting a wide character's lead cell leaves its
spacer as a blank rather than closing the gap, which would shift everything
after it one column left.

The banner lost its span when the gutter became padding — a plain block child
only reaches the content box, so the reserved column was painted in the body
color and the card's top-left radius drawn in it. Invisible in the light
theme, where banner and body share a token; visible in the dark one. The
header now pulls back across the gutter and re-insets by the same amount.

The replay trigger matches the same CSI shape the parser accepts, so a form
like `\x1b[1;2K` can no longer skip its own erase, and `replayLine`'s JSDoc
documents its new parameter and returned pair.

Docs: four places still described the dot as sitting left of the card surface,
which stopped being true when the gutter became the card's own padding, and
two fixture comments still referenced the exit marker that was deliberately
removed.
This commit is contained in:
Chinesezjc
2026-07-29 16:10:37 +08:00
parent c87a20b506
commit b588060b90
11 changed files with 127 additions and 37 deletions
@@ -30,9 +30,15 @@
display: flex;
align-items: flex-start;
gap: 12px;
/* 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;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
+59 -17
View File
@@ -87,9 +87,24 @@ const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* Lines whose cursor movements have to be replayed: a carriage return, a
* backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
* `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
* form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
*/
const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
/** Terminal tab stop width; a tab advances to the next multiple of this. */
const TAB_WIDTH = 8
/**
* Combining marks and other zero-width code points: a terminal advances no
* column for them, so `e` + U+0301 occupies one cell and a two-column redraw
* covers both code points.
*/
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
/**
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
* CJK punctuation, and the emoji/symbol blocks a command's output realistically
@@ -129,13 +144,16 @@ function isWide(char: string): boolean {
* `ok` overwrote two cells and the third kept the state it was written with.
* The columns are re-emitted as runs, so anser sees that same styling.
* @param line - one output line, still carrying its CSI sequences.
* @returns the line as the terminal would have it after every movement.
* @param entrySgr - SGR state in force when the line begins, since a newline
* does not reset it.
* @returns the line as the terminal would have it after every movement, plus the
* SGR state at its end for the next line to enter with.
*/
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
/** Per column: the SGR state in force when it was written, and its character. */
const columns: ({ sgr: string; char: string } | undefined)[] = []
const columns: ({ sgr: string; char: string; spacer?: boolean } | 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
@@ -156,11 +174,20 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
continue
}
if (ZERO_WIDTH.test(char)) {
// No column of its own: it attaches to the cell already written, so a
// redraw that covers that cell covers the mark with it.
const at = Math.max(0, cursor - 1)
const base = columns[at]
if (base !== undefined) columns[at] = { sgr: base.sgr, char: base.char + 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++ }
// A wide character occupies two columns; the trailing one is a spacer,
// marked so that overwriting the lead cell leaves a blank behind instead
// of closing the gap and shifting everything after it left.
if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
}
}
@@ -174,11 +201,15 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
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
// standing, which is text the terminal never showed. `1` blanks from the
// line start THROUGH the cursor column (inclusive, per the CSI spec)
// rather than dropping those cells, since the cursor does not move and a
// later write can still land past them.
// Only the FIRST parameter selects the mode; a terminal ignores the rest
// (`1;2K` erases exactly as `1K` does — verified against a real terminal).
const mode = params.split(';')[0] ?? ''
if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' }
else columns.length = mode === '2' ? 0 : cursor
continue
}
// Only SGR carries graphic state; every other final byte is a cursor or
@@ -193,18 +224,29 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
// before the cursor, which a terminal paints as blanks.
let out = ''
let active = entrySgr
for (const slot of columns) {
const column = slot ?? { sgr: '', char: ' ' }
for (let index = 0; index < columns.length; index++) {
const column = columns[index] ?? { sgr: '', char: ' ' }
if (column.sgr !== active) {
if (active !== '') out += '\u001b[0m'
out += column.sgr
active = column.sgr
}
out += column.char
// A spacer still holds its column. While its lead cell survives, the wide
// glyph spans both and the spacer emits nothing; once a later write replaced
// that lead, the terminal blanks the spacer instead of closing the gap, so
// emitting nothing would shift everything after it one column left.
const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
out += column.spacer === true && !leadIntact ? ' ' : column.char
}
// 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 }
// Converge to the state the SCAN ended in, not the last written cell's: a
// sequence after the final write (the `\x1b[0m` closing a colored line) changes
// no cell yet still ends the run, and it has to reach both the DOM and the
// next line. Without this a line ending in a reset leaked its color onward.
if (active !== sgr) {
if (active !== '') out += '\u001b[0m'
out += sgr
}
return { text: out, sgr }
}
/**
@@ -227,7 +269,7 @@ function applyCursorMovements(text: string): string {
// 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)
replayed.push(NEEDS_REPLAY.test(line) ? result.text : line)
sgr = result.sgr
}
return replayed.join('\n')