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

View File

@@ -92,7 +92,7 @@ function sgr(code: number, body: string): string {
}
/**
* Terminal output sample for fixture turn 66, authored to carry every feature
* Terminal output sample for fixture turn 65, authored to carry every feature
* the terminal card draws that turn 60's two prompt rows cannot reach:
* 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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 3e77cb1955972db602b4e751a52a45cf5f5f349d
README.zh.md: 19e02c3e466afcbddd0d19ad6644887d3a53ca10
README.md: fda45aaea8e6ad640c0002cf34e654d49e63582a
README.zh.md: 94e99b838e102266fb3b5868d3ceb06ae0633b15

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage return and backspace replay into a per-line column buffer before inert controls are stripped, since both only move the cursor (so `100%` + CR + `OK` shows `OK0%`), with SGR state stamped per column as a terminal stores it per cell; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage return and backspace replay into a per-line column buffer before inert controls are stripped, since both only move the cursor (so `100%` + CR + `OK` shows `OK0%`), with SGR state stamped per column as a terminal stores it per cell; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Model Experience

View File

@@ -10,7 +10,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span回车与退格在剥除无显示意义控制符之前先重放进逐行的列缓冲因为两者都只移动光标所以 `100%` 加回车再加 `OK` 显示为 `OK0%`),且 SGR 状态按列打戳,与终端按单元格存储颜色一致;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span回车与退格在剥除无显示意义控制符之前先重放进逐行的列缓冲因为两者都只移动光标所以 `100%` 加回车再加 `OK` 显示为 `OK0%`),且 SGR 状态按列打戳,与终端按单元格存储颜色一致;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## 模型体验

View File

@@ -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);

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')

View File

@@ -293,6 +293,47 @@ describe('parseAnsiLines: erase and column arithmetic', () => {
})
})
describe('parseAnsiLines: line-end state and column widths', () => {
it('closes a run whose reset lands after the last written cell', () => {
// Verified in a real terminal: `\x1b[32mdone\rok\x1b[0m` then `plain` shows
// `okne` GREEN and `plain` in the DEFAULT color. The reset changes no cell,
// so returning the last cell's state leaked green onto every later line —
// and this exact shape (`\r\x1b[K\x1b[32m✓ built\x1b[0m`) is what every
// build tool writes.
expect(parseAnsiLines(`${ESC}[32mdone\rok${ESC}[0m\nplain`)).toEqual([
[{ text: 'okne', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'plain', style: undefined }],
])
})
it('erases through the cursor column for 1K, not up to it', () => {
// Verified in a real terminal: `abcd\b\x1b[1K|` shows ` |` — the `d` under
// the cursor is erased too, which the CSI spec calls inclusive.
expect(onlySpan(`abcd${BS}${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
})
it('gives a combining mark no column of its own', () => {
// Verified in a real terminal: `é` (e + U+0301) then `x`, redrawn with `YZ`,
// shows `YZ`. Counting the mark as a column left the `x` standing.
expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined })
})
it('blanks a wide character\'s spacer once its lead cell is overwritten', () => {
// Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide
// glyph's second cell becomes a blank rather than closing the gap, so the
// `x` keeps column 3.
expect(onlySpan('中x\rA')).toEqual({ text: 'A x', style: undefined })
// Covering both of its columns leaves no spacer behind.
expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
})
it('replays an erase whose parameters carry a semicolon', () => {
// The replay guard has to match the same CSI shape the parser accepts, or a
// form like `\x1b[1;2K` skips the replay and its erase never happens.
expect(onlySpan(`abcd${ESC}[1;2K|`)).toEqual({ text: ' |', 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.