From fdcb45b619896fce2b87d805286d8072dfcdb87e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 29 Jul 2026 16:42:10 +0800 Subject: [PATCH] fix(web): bound the SGR state and follow real terminal widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine findings, one critical. Terminal cases verified in a real terminal first. CRITICAL: cells held the accumulated SGR history, so every state boundary re-emitted the whole chain — output switching color without a full reset emitted O(n^2) characters. Measured: 3200 such cells produced 25 MB, and the reviewer's ~90 KB alternating-color case is well under bash's own output cap. State is now a normalized record (foreground, background, attribute set) with one canonical sequence per boundary, so the emitted text is linear in cells; the 90 KB case parses in 36 ms. That also makes the attribute closers every chalk-based tool writes actually close: `\x1b[1mbold\x1b[22mplain` leaves the following write PLAIN, which a real terminal confirms. Width follows emoji presentation, not the U+2600-U+27BF block: `A✓B` redrawn with `XY` shows `XYB`, so the check every progress line writes is ONE column. Taking the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing. `line\n\x1b[0m` does not end in a newline as a string yet its last parsed line holds nothing visible, so the terminator check now reads the parsed lines — it had added a blank row and inflated the collapse count. A line with no cursor movement no longer builds a column buffer at all; only its SGR is folded, so an `ls -R` or a 5k-line log allocates nothing per character. The `.terminalDescription` rule had been inserted into an existing grouped selector, silently giving `.codeBody` description typography and changing its bottom margin from 4px to 0 — a pre-existing surface this PR does not own. Split out, `.codeBody`'s margin restored. Three comments contradicted their code: the fixture's exit-marker line (still claiming recovery from a marker deliberately removed), `bash-sample`'s header (still routing a click to the details panel, and calling the consumer's cap the block's own), and a DetailsPanel comment stacked above the wrong rule. The ui-primitives README documented only the CR/BS half of the replay, so a reader would expect `OK0%` where `100%\r\x1b[KOK` renders `OK`. --- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 2 +- .../2026-07-28-web-terminal-card.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 6 +- .../src/client/chat/ToolRow.module.css | 14 +- .../client/skeleton/DetailsPanel.module.css | 4 +- .../src/client/toolviews/bash-sample.tsx | 10 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/TerminalBlock.tsx | 16 +- packages/client/ui-primitives/src/ansi.ts | 192 ++++++++++++++---- .../client/ui-primitives/tests/ansi.spec.ts | 69 +++++++ .../tests/terminal-block.spec.tsx | 9 + 14 files changed, 274 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 7a7f6e3720..d9ed8345dc 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: f580e3c8c17b8701ae223ea3ed910fa57b915278 -2026-07-28-web-terminal-card.zh.md: eb48fd3a1bcff9599b138630113460fe867a6aef +2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823 +2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index f580e3c8c1..14896b1d88 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -20,7 +20,7 @@ The component's contract: - **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter the card reserves as its OWN left padding, so it neither indents its command nor depends on the command's text metrics to line up. The reservation is padding rather than margin because every render site rewrites `margin` wholesale to set its own indent, which silently cancelled a margin-based gutter and let a container clip the dot. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes. - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding. - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends. -- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because 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. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because `\r\x1b[K` is the single idiom every spinner and progress bar writes — modelling the `\r` alone left the previous frame's tail standing, which is text the terminal never showed. Only `m` accumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in `\x1b[0m` leaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none: `a\tb` then a redraw of `XY` shows `XY b`, since a two-character redraw cannot reach column 8. +- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because 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. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because `\r\x1b[K` is the single idiom every spinner and progress bar writes — modelling the `\r` alone left the previous frame's tail standing, which is text the terminal never showed. Only `m` accumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. SGR is held per cell as a NORMALIZED record (foreground, background, attribute set), not as the sequence history: accumulating raw sequences made every state boundary re-emit the whole chain, so output that switches color without a full reset emitted O(n^2) characters — 3200 such cells produced 25 MB and a `RangeError` well under bash's own output cap. The record also lets the attribute closers every chalk-based tool writes (`39`, `49`, `22`, `24`, …) actually close their attribute, and each boundary emits one canonical sequence for the state it opens. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in `\x1b[0m` leaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none. Width follows emoji PRESENTATION rather than the U+2600-U+27BF block: `\u2713`, the check every progress line writes, is one column, so treating the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing: `a\tb` then a redraw of `XY` shows `XY b`, since a two-character redraw cannot reach column 8. - **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder — judged on the parsed lines the card renders, not on the raw text, since output that is only escapes or control bytes survives a `trim()` yet parses to nothing visible and would otherwise draw blank rows plus a copy control for invisible bytes. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard. Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced code block match visually; `white-space: pre` plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of `CodeBlock` into a package-internal `src/clipboard.ts`, unexported so it stays an implementation detail of the two blocks. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index eb48fd3a1b..16c9004f8f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -20,7 +20,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c - **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以**自身左内边距**预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin,是因为每个渲染点都会整条重写 `margin` 来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。 -- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。 +- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39`、`49`、`22`、`24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600–U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。 - **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。 几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f4b13f7c99..68d3374291 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -284,8 +284,10 @@ function buildAlphaLog(): SessionEvent[] { ] // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover — // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd - // whose prompt label is its last segment, and a non-zero exit recovered from - // the trailing marker the bash tool appends. Named `bash`, so it also covers + // whose prompt label is its last segment, and a non-zero exit authored beside + // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no + // `[exit code: N]` marker, since the real presenter consumes that one out of + // the body. Named `bash`, so it also covers // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback // row) — the two chat-row shapes the terminal card renders in. // diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 0c1f674262..4ff289388e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -182,14 +182,16 @@ button.leading { also replaces each primitive's own standalone vertical spacing with the flow's row rhythm. */ .codeBody, -/* Indented to the terminal body's own column, so the description reads as the - card's heading rather than as another summary row. */ +.terminalBody { + margin: 4px 0 4px 22px; +} + +/* Indented to the body's own column so the description reads as the card's + heading rather than as another summary row, and sits tight against the card + below it. Its own rule: grouping it with a body would put description + typography on a `CodeBlock` wrapper and change that body's spacing. */ .terminalDescription { margin: 4px 0 0 22px; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xs-13); } - -.terminalBody { - margin: 4px 0 4px 22px; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 0ddc58e30b..143174fe42 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -93,8 +93,6 @@ color: var(--dsw-alias-state-error-primary); } -/* The terminal card sits directly under its section label, so it drops the - primitive's standalone vertical margin; the section owns the spacing. */ /* Above the card, which is where the render-intent contract puts a terminal call's description; the panel has no summary row to carry it. */ .terminalDescription { @@ -103,6 +101,8 @@ font: var(--dsw-font-xs-13); } +/* The terminal card sits directly under its section label, so it drops the + primitive's standalone vertical margin; the section owns the spacing. */ .terminal { margin: 0; } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 1e6e9183fc..c9385cab04 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -6,10 +6,12 @@ // // A bash call declares the terminal render intent, so this row also renders // the command's own output through TerminalBlock. This row has no expand -// control (a click goes to the details panel), so its terminal body is -// resident rather than expand-gated as in ToolRow; the block's own height cap -// (CHAT_TERMINAL_MAX_LINES) and internal expander keep a long output from -// taking over the message flow. +// control and is not a details-panel target either (tool rows stopped being +// one), so its terminal body is resident rather than expand-gated as in +// ToolRow, and the card's own copy and expand controls are the row's only +// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat +// flow's tighter cap over the block's own default of 16 — and the block's +// internal expander keeps a long output from taking over the message flow. import type { Context } from 'cordis' import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 17ef845b9e..9c58403c04 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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: fda45aaea8e6ad640c0002cf34e654d49e63582a -README.zh.md: 94e99b838e102266fb3b5868d3ceb06ae0633b15 +README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a +README.zh.md: 51d3c7a3f3dd46e487a3031cfa1f3d93bace336a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index fda45aaea8..1236054d5a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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 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). +`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; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; 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 @@ -26,4 +26,4 @@ None; this package neither assembles nor sends a provider request. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. - **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. -- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, while cursor movement, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. +- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 94e99b838e..51d3c7a3f3 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 ## 模型体验 @@ -26,4 +26,4 @@ - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 - **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 -- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,而光标移动、清屏和备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 +- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 1d56050b3c..c707711f69 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -126,9 +126,19 @@ export function TerminalBlock({ }: TerminalBlockProps) { const text = output ?? '' // A command's output ends with a newline; that terminator is not an extra - // blank line to draw or to count against the height cap. The copy control - // still copies `text` untouched. - const lines = useMemo(() => parseAnsiLines(text.endsWith('\n') ? text.slice(0, -1) : text), [text]) + // blank line to draw or to count against the height cap. The check runs on the + // PARSED lines rather than on the raw text, because a reset after the final + // newline (`line\n\x1b[0m`) leaves the string not ending in one while still + // producing a last line with nothing visible in it. A genuinely blank final + // line — the double newline — survives, since it has a real empty line before + // the terminator. The copy control still copies `text` untouched. + const lines = useMemo(() => { + const parsed = parseAnsiLines(text) + const last = parsed[parsed.length - 1] + const terminated = parsed.length > 1 && last !== undefined + && last.every(span => span.text === '') + return terminated ? parsed.slice(0, -1) : parsed + }, [text]) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts index a26d1f2dc7..9eeba29e12 100644 --- a/packages/client/ui-primitives/src/ansi.ts +++ b/packages/client/ui-primitives/src/ansi.ts @@ -95,6 +95,9 @@ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g */ const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/ +/** SGR sequences alone, for folding state through a line that needs no replay. */ +const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g + /** Terminal tab stop width; a tab advances to the next multiple of this. */ const TAB_WIDTH = 8 @@ -107,12 +110,18 @@ 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 - * carries. + * CJK punctuation, and characters with emoji presentation. Text-presentation + * symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and + * must stay out of this set. */ 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]', + // Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE + // width — `\u2713` (the check every progress line writes, this fixture + // included) advances one column, verified against a real terminal, so taking + // the whole block as wide misaligned exactly the output this card exists for. + + '|\\p{Emoji_Presentation}' + + '|[\\uff01-\\uff60\\u3000-\\u303e]', 'u', ) @@ -129,6 +138,87 @@ function isWide(char: string): boolean { return WIDE_CHAR.test(char) } +/** + * A cell's graphic state, normalized. Held as fields rather than as the raw + * sequence history because a terminal tracks CURRENT state, not a transcript: + * accumulating sequences made each state boundary re-emit the whole chain, so + * output that switches color without a full reset emitted O(n^2) characters + * (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes + * the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`, + * `24`, `27`, `29` — actually close their attribute instead of appending to it. + */ +interface SgrState { + fg: string + bg: string + /** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */ + attrs: readonly string[] +} + +/** The default state: no color, no attributes. */ +const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] } + +/** Attribute closers, mapped to the opener parameters each one turns off. */ +const ATTR_CLOSERS: Record = { + 22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'], +} + +/** + * Fold one SGR sequence's parameters into the state it produces. + * @param state - state in force before the sequence. + * @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`). + * @returns the state the sequence leaves in force. + */ +function foldSgr(state: SgrState, params: string): SgrState { + const codes = params === '' ? ['0'] : params.split(';') + let next = state + for (let index = 0; index < codes.length; index++) { + const code = String(codes[index]) + if (code === '' || code === '0') { next = SGR_NONE; continue } + // Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair + // consume their own arguments, so they are taken whole. + if (code === '38' || code === '48') { + const kind = codes[index + 1] ?? '' + const span = kind === '2' ? 4 : kind === '5' ? 2 : 0 + const value = codes.slice(index, index + span + 1).join(';') + next = code === '38' ? { ...next, fg: value } : { ...next, bg: value } + index += span + continue + } + const closes = ATTR_CLOSERS[code] + if (closes !== undefined) { + next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) } + continue + } + const numeric = Number(code) + if (code === '39') { next = { ...next, fg: '' }; continue } + if (code === '49') { next = { ...next, bg: '' }; continue } + if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue } + if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue } + if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] } + } + return next +} + +/** + * Render a state as the one canonical sequence that establishes it from the + * default, so a boundary emits a bounded string no matter how the state was + * reached. + * @param state - the state to open. + * @returns the SGR sequence, or the empty string for the default state. + */ +function openSgr(state: SgrState): string { + const codes = [...state.attrs] + if (state.fg !== '') codes.push(state.fg) + if (state.bg !== '') codes.push(state.bg) + return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m` +} + +/** Whether two states are the same, so a boundary is only emitted on a change. */ +function sameSgr(a: SgrState, b: SgrState): boolean { + return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length + && a.attrs.every((attr, index) => attr === b.attrs[index]) +} + /** * 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 @@ -149,19 +239,29 @@ function isWide(char: string): boolean { * @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 } { +function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } { // 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; spacer?: boolean } | undefined)[] = [] + /** Per column: the state in force when it was written, and its character. */ + const columns: (Cell | 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. It - // enters carrying the previous line's state, since a newline does not reset it. + // State is tracked 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. It enters carrying the previous line's + // state, since a newline does not reset it. let sgr = entrySgr let at = 0 + /** Clear a cell and, for a wide pair, its partner: a terminal erases both. */ + const clear = (index: number, fill: string): void => { + const cell = columns[index] + if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill } + else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) { + columns[index + 1] = { sgr, char: fill } + } + columns[index] = { sgr, char: fill } + } + const consume = (chunk: string): void => { for (const char of chunk) { if (char === '\r') { cursor = 0; continue } @@ -176,13 +276,16 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string } 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. - // With no cell to attach to (line start, or straight after a redraw to - // column 0) a terminal shows nothing rather than a lone accent. + // redraw that covers that cell covers the mark with it. With no cell to + // attach to (line start, or straight after a redraw to column 0) a + // terminal shows nothing rather than a lone accent. const base = cursor > 0 ? columns[cursor - 1] : undefined if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char } continue } + // Writing over either half of a wide pair blanks the other half, since a + // terminal cannot leave one cell of a two-cell glyph standing. + clear(cursor, ' ') columns[cursor] = { sgr, char } cursor++ // A wide character occupies two columns; the trailing one is a spacer, @@ -205,31 +308,31 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string // 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). + // 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`). const mode = String(params.split(';')[0]) - if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' } + if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ') else columns.length = mode === '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. + // erase action that must not affect a cell's style. if (final !== 'm') continue - sgr = /^0?$/.test(params) ? '' : sgr + match[0] + sgr = foldSgr(sgr, params) } consume(line.slice(at)) - // 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. + // Re-emit the columns, opening a run only where its state changes, so anser + // sees the same styling a terminal shows. Each boundary emits ONE canonical + // sequence for the state it opens, which is what keeps the output linear in + // the number of cells however the state was reached. let out = '' let active = entrySgr 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 + const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' } + if (!sameSgr(column.sgr, active)) { + if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m' + out += openSgr(column.sgr) active = column.sgr } // A spacer still holds its column. While its lead cell survives, the wide @@ -243,13 +346,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string // 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 + if (!sameSgr(active, sgr)) { + if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m' + out += openSgr(sgr) } return { text: out, sgr } } +/** One replayed column: the state it was written with, and its character. */ +interface Cell { + sgr: SgrState + char: string + /** The trailing half of a wide character's two-column pair. */ + spacer?: boolean +} + /** * 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 @@ -260,18 +371,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string */ function applyCursorMovements(text: string): string { const replayed: string[] = [] - let sgr = '' + let sgr = SGR_NONE 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(NEEDS_REPLAY.test(line) ? result.text : line) - sgr = result.sgr + if (NEEDS_REPLAY.test(line)) { + const result = replayLine(line, sgr) + replayed.push(result.text) + sgr = result.sgr + continue + } + // No cursor movement: the line needs no column buffer, and painting one + // would allocate a cell per character of output this card never redraws — + // an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later + // line that DOES replay enters with the right state. + replayed.push(line) + for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1])) } return replayed.join('\n') } diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.spec.ts index e06e4a0179..d3efd5ffb3 100644 --- a/packages/client/ui-primitives/tests/ansi.spec.ts +++ b/packages/client/ui-primitives/tests/ansi.spec.ts @@ -357,6 +357,75 @@ describe('parseAnsiLines: line-end state and column widths', () => { }) }) +describe('parseAnsiLines: bounded state and true widths', () => { + it('emits one canonical sequence per boundary however the state was reached', () => { + // Colors that never fully reset used to accumulate raw sequence history per + // cell, so every boundary re-emitted the whole chain: 3200 such cells + // produced 25 MB and eventually a RangeError. The state is normalized now, + // so the emitted text stays linear in the number of cells. + let input = '' + for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx` + const emitted = parseAnsiLines(`${input}\rz`)[0] ?? [] + expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000) + }) + + it('closes an attribute with its closer instead of appending to the state', () => { + // `1` then `22` is bold then not-bold, which every chalk-based tool writes; + // appending both left the cell bold and grew the chain. + // Verified in a real terminal: the `22` closes the bold, so the `x` written + // after the redraw is PLAIN. Appending both left it bold and grew the chain. + expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[ + { text: 'x', style: undefined }, + ]]) + expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[ + { text: 'A', style: { fontWeight: 700 } }, + { text: 'B', style: undefined }, + ]]) + }) + + it('folds extended colors, backgrounds and every attribute closer', () => { + // The 256-palette and truecolor forms consume their own arguments, so the + // fold has to take them whole rather than as separate codes. + expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[ + { text: 'B', style: { color: 'rgb(255, 135, 0)' } }, + ]]) + expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[ + { text: 'B', style: { color: 'rgb(10, 20, 30)' } }, + ]]) + // A background survives the same way, and `49` closes it. + expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[ + { text: 'C', style: undefined }, + ]]) + // Each closer drops only its own attribute: `4` underline closed by `24` + // while the italic opened before it stays in force. + expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[ + { text: 'C', style: { fontStyle: 'italic' } }, + ]]) + // `39` closes a foreground without touching the background. + expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[ + { text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } }, + ]]) + }) + + it('treats a text-presentation symbol as one column', () => { + // Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the + // check mark is ONE column. Taking the whole U+2600-U+27BF block as wide + // misaligned exactly the progress output this card exists to show. + expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined }) + // An emoji-presentation character is two, so the same redraw leaves a blank. + expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined }) + }) + + it('blanks both halves of a wide pair when either is overwritten', () => { + // A terminal cannot leave one cell of a two-cell glyph standing, so writing + // over the spacer clears the lead as well. + // Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and + // the second glyph — writing the lead cell blanks its spacer, so the column + // stays occupied rather than collapsing. + expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', 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. diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx index 6b712414c5..13121351a8 100644 --- a/packages/client/ui-primitives/tests/terminal-block.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -146,6 +146,15 @@ describe('TerminalBlock states', () => { expect(outputLines(view.container)).toEqual(['a', 'b']) }) + it('drops the output terminator even when a reset follows the final newline', () => { + // `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed + // line holds nothing visible — a common shape, since tools close their color + // after the last line. Judging the terminator on the raw text added a blank + // row and inflated both the card height and the collapse count. + const view = render() + expect(outputLines(view.container)).toEqual(['a', 'b']) + }) + it('keeps a genuinely blank final line when the output ends with two newlines', () => { const view = render() expect(outputLines(view.container)).toEqual(['a', 'b', ''])