fix(web): replay cursor movements the way a terminal paints them

Carriage return and backspace only MOVE the cursor; neither erases. Both of
my earlier approximations were wrong, and I checked each case against a real
terminal rather than reasoning about it:

`100%\rOK` shows `OK0%`, not `OK` — the redraw is shorter than the frame
beneath it, so the tail stands. `abc\b` still shows `abc`, not `ab` — a
trailing backspace has nothing to overwrite. `\x1b[31mgone\rkept` paints
`kept` RED, because a carriage return does not reset the graphic state, which
one of my own tests had asserted the opposite of.

Both now replay into a per-line column buffer with SGR state stamped per
column, as a terminal stores it per cell. That gives the partial-overwrite
case its real result too: red `bad`, three backspaces, then `ok` shows `okd`
with the `d` still red, since `ok` reached only two of the three cells.

The presenter description now also renders at every site. An expanded row
draws it itself — the collapsed summary is hidden while open, so otherwise
the description was visible only collapsed, the opposite of "above the card"
— and the details panel draws it above the card as well.

Three of my own tests encoded the wrong semantics and were corrected with
their behavior, and the emit loop's gap-filling arm was removed as
unreachable: `\r` and backspace only move left, so no column can be unwritten.
This commit is contained in:
Chinesezjc
2026-07-29 14:39:21 +08:00
parent bbe1481a9e
commit f378873b22
13 changed files with 182 additions and 85 deletions

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: 4222aac4fa1d9c89a3d3c702ee8391fba1eef178
README.zh.md: e7a5b5ad57048bc59872963b0068c6f88fc772f8
README.md: 3e77cb1955972db602b4e751a52a45cf5f5f349d
README.zh.md: 19e02c3e466afcbddd0d19ad6644887d3a53ca10

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 redraws and backspace overwrites resolve as a terminal performs them before inert controls are stripped; 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 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).
## 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回车重绘与退格覆盖会按终端的行为先行结算,之后才剥除无显示意义控制符;基础 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

@@ -82,95 +82,99 @@ const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
/**
* C0 controls with no display meaning here. Tab, newline, backspace and ESC
* survive: the first two for layout, backspace for its overwrite, ESC for
* anser's CSI split.
* survive: the first two for layout, backspace for the cursor replay, ESC
* for anser's CSI split.
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* Apply carriage-return redraws: within a line, only the text after the last
* `\r` survives, which is what a terminal shows for progress output. A `\r`
* that only terminates a CRLF line is dropped first so those lines keep
* their text. SGR codes preceding a dropped redraw are dropped with it.
* @param text - output text, already free of OSC and non-CSI escapes.
* @returns the text with each line reduced to its final redraw.
*/
function applyCarriageReturns(text: string): string {
return text.split('\n').map((raw) => {
const line = raw.replace(/\r+$/, '')
return line.slice(line.lastIndexOf('\r') + 1)
}).join('\n')
}
/**
* Apply backspaces as the cursor-left-then-overwrite a terminal performs, so
* `abc` followed by two backspaces and `XY` reads `aXY` instead of keeping the
* characters it overwrote. Progress meters and captured PTY output use
* backspace this way. Resolved per line, so a backspace neither eats the
* newline before it nor reaches into the previous line's tail; one at a line
* start has nothing to erase.
* @param text - output text, already reduced to its carriage-return redraws.
* @returns the text with each backspace resolved against the character before it.
*/
function applyBackspaces(text: string): string {
if (!text.includes('\u0008')) return text
return text.split('\n').map(applyBackspacesToLine).join('\n')
}
/**
* One line's backspaces, resolved over VISIBLE characters only. A CSI sequence
* moves no cursor, so it must survive intact: erasing its bytes would corrupt
* the sequence and repaint the rest of the output with whatever the mangled
* remainder parses as. The sequences are therefore held as indivisible units
* that a backspace steps over on its way to the last printed character, and a
* unit already erased stays erased so a run's own color still applies to what
* remains of it.
* 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
* erases anything — so what a reader sees is whatever each column last had
* written to it. That distinction is the whole point of doing this as a buffer
* rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
* shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
* because nothing ever overwrote the `c`.
*
* A CSI sequence occupies no column; it changes the state that the NEXT writes
* are stamped with, which is how a terminal stores color per cell. `red bad`
* then three backspaces then `ok` therefore shows `okd` with the `d` still red:
* `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 with each backspace applied to the character before it.
* @returns the line as the terminal would have it after every movement.
*/
function applyBackspacesToLine(line: string): string {
if (!line.includes('\u0008')) return line
const units: { text: string; visible: boolean }[] = []
// Same shape anser splits on: CSI ... final byte. Matched here so a sequence
// is one unit rather than a run of erasable characters.
function replayLine(line: string): 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 }[] = []
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 = ''
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 }
columns[cursor] = { sgr, char }
cursor++
}
}
for (const match of line.matchAll(csi)) {
for (const char of line.slice(at, match.index)) units.push({ text: char, visible: true })
units.push({ text: match[0], visible: false })
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
}
for (const char of line.slice(at)) units.push({ text: char, visible: true })
consume(line.slice(at))
const kept: { text: string; visible: boolean }[] = []
for (const unit of units) {
if (unit.visible && unit.text === '\u0008') {
// Walk back past any escapes to the last printed character and drop it,
// keeping those escapes so the surviving text stays styled as authored.
for (let index = kept.length - 1; index >= 0; index--) {
if (kept[index]?.visible !== true) continue
kept.splice(index, 1)
break
}
continue
// 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.
let out = ''
let active = ''
for (const column of columns) {
if (column.sgr !== active) {
if (active !== '') out += '\u001b[0m'
out += column.sgr
active = column.sgr
}
kept.push(unit)
out += column.char
}
return kept.map(unit => unit.text).join('')
return active === '' ? out : `${out}\u001b[0m`
}
/**
* 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.
* @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')
}
/**
* Remove every escape sequence and control character that carries no color,
* leaving CSI sequences for anser and `\n`/`\t` for layout. Carriage-return
* redraws and backspace overwrites resolve first: both are cursor movements
* whose effect on the visible text must land before the characters that
* expressed them are dropped.
* leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
* (carriage return, backspace) replay first, since their effect on the visible
* text must land before the characters that expressed them are dropped.
* @param text - raw command output.
* @returns text whose only remaining escapes are CSI sequences.
*/
function sanitize(text: string): string {
const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
return applyBackspaces(applyCarriageReturns(escaped)).replace(INERT_CONTROL, '')
return applyCursorMovements(escaped).replace(INERT_CONTROL, '')
}
/**

View File

@@ -151,8 +151,26 @@ describe('parseAnsiLines: carriage returns', () => {
expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
})
it('drops the SGR codes that preceded a discarded redraw', () => {
expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined })
it('leaves the tail of a longer frame standing under a shorter redraw', () => {
// Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage
// return only moves the cursor, so the two columns the redraw never reaches
// still hold the frame beneath — truncating to the last `\r` would lose them.
expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined })
expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined })
})
it('clamps a backspace run at the line start rather than going negative', () => {
// More backspaces than characters: the cursor stops at column 0, so the
// following write simply overwrites from there.
expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined })
})
it('keeps SGR state in force across a redraw, as a terminal does', () => {
// Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED.
// A carriage return moves the cursor; it does not reset the graphic state,
// so the redraw inherits the color the discarded frame was written with.
expect(onlySpan(`${ESC}[31mgone\rkept`))
.toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } })
})
it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
@@ -184,6 +202,17 @@ describe('parseAnsiLines: backspaces', () => {
])
})
it('treats a trailing backspace as a cursor move, not a delete', () => {
// Verified against a real terminal: `abc\b` still shows `abc`. Only a later
// write overwrites; a backspace with nothing after it erases nothing.
expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined })
// Same at a line boundary: the newline ends the line before any overwrite.
expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([
[{ text: 'abc', style: undefined }],
[{ text: 'def', style: undefined }],
])
})
it('steps over an SGR sequence instead of erasing its bytes', () => {
// `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
// corrupt it and repaint the rest of the line with whatever the remainder
@@ -202,14 +231,21 @@ describe('parseAnsiLines: backspaces', () => {
]])
})
it('applies the overwrite after a carriage-return redraw, not before', () => {
// The redraw wins first; the backspace then erases inside what survived.
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined })
it('replays a redraw and a trailing backspace as pure cursor moves', () => {
// Verified against a real terminal: `old\rnew\b` shows `new`. The redraw
// repaints all three columns and the trailing backspace only moves the
// cursor left — nothing overwrites the `w`, so nothing is lost.
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined })
})
it('keeps the run\'s style while erasing its own characters', () => {
expect(onlySpan(sgr('31', `bad${BS}${BS}${BS}ok`)))
.toEqual({ text: 'ok', style: { color: 'var(--dsw-alias-state-error-primary)' } })
it('overwrites only the columns the later write reaches, keeping the rest styled', () => {
// Verified against a real terminal: red `bad`, three backspaces, then `ok`
// shows `okd` — the cursor returned to column 0 and `ok` overwrote two of
// the three columns, so the untouched `d` keeps the run's red.
expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[
{ text: 'ok', style: undefined },
{ text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } },
]])
})
})