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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user