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 6927b58937..14580fce67 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: 284c91cc936c1e757f20f0d0d8afbe4b4c0afc42 -2026-07-28-web-terminal-card.zh.md: 3ffcf232942eed883f44da583c68455715d347ce +2026-07-28-web-terminal-card.md: 3ebbc38a2875a8a3c0fc6c771e282ca7974ad905 +2026-07-28-web-terminal-card.zh.md: 0523b5220a5b856551a95f4a7c36770da21c6906 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 284c91cc93..3ebbc38a28 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 @@ -17,11 +17,11 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ The component's contract: - **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a `cd` in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare `$` so they still read as prompts. -- **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 reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. 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. +- **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. -- **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. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard. +- **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. The cursor advances by terminal columns, so a tab reaches the next 8-column stop and a wide character takes two: `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. @@ -55,11 +55,11 @@ Inline rendering is licensed for the terminal intent alone. A future intent that ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws and backspace overwrites (including a backspace stopping at a line start and resolving after a redraw), and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. +`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. `packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. -`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker. +`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce. `apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet) and starts to the left of the card surface itself, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `terminal-card.expected.md` golden records the resolved workspace in the prompt row, which is what a bash call with no `workdir` must show instead of a bare `$`. 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 3ffcf23294..0523b5220a 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 @@ -17,11 +17,11 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c 该组件的契约: - **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。 -- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片表面左侧预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。 +- **整次调用一枚运行状态点,位于第一行。** 它是 `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 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因。 -- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。 +- **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 只能丢弃的边界。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。 +- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。 几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。 @@ -55,11 +55,11 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘与退格覆盖(含退格停在行首、以及退格在重绘之后结算),以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 +`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 `packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 -`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。 +`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。 `apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且其起点位于卡片表面本身的左侧;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `terminal-card.expected.md` 基准记录了提示行中已解析的 workspace——这正是不带 `workdir` 的 bash 调用应当显示的内容,而非一个裸 `$`。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 12ec836e90..96117c21bf 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -220,16 +220,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // puts it to the left of the `$`. beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!) === Node.DOCUMENT_POSITION_FOLLOWING, - // The dot is out of flow in the card's left gutter, so it starts to the - // left of the card surface itself — the geometry jsdom cannot compute. - leftOfCard: (node as HTMLElement).getBoundingClientRect().left - < node.closest('[data-terminal]')!.getBoundingClientRect().left, + // The dot lives in the card's OWN left padding, so it sits inside the + // card box yet left of the prompt text. Owning the reservation as padding + // rather than margin is what keeps a consumer's own margin from + // cancelling it and letting a container clip the dot — geometry jsdom + // cannot compute. + insideCard: (node as HTMLElement).getBoundingClientRect().left + >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity), + leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right + <= (node.closest('[class*="_promptLine_"]') + ?.querySelector('[class*="_cwd_"]') + ?.getBoundingClientRect().left ?? -Infinity), } }) expect(dot.state).toBe('done') expect(dot.label).toBe('已完成') expect(dot.beforePrompt).toBe(true) - expect(dot.leftOfCard).toBe(true) + expect(dot.insideCard).toBe(true) + expect(dot.leftOfPrompt).toBe(true) // Resolved through the theme token, not a literal hex in the component. expect(dot.success).toMatch(/^rgb/) expect(dot.color).toBe(dot.success) diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts index 82d94b251d..3d4d81169c 100644 --- a/apps/web/tests/terminal-card.snapshot.ts +++ b/apps/web/tests/terminal-card.snapshot.ts @@ -210,18 +210,18 @@ it('renders the keyed bash row with a resident terminal card', async () => { ], "expander": { "expanded": "false", - "label": "展开其余 14 行输出", - "text": "… 其余 14 行", + "label": "展开其余 13 行输出", + "text": "… 其余 13 行", }, "lines": [ "Running 4 checks", "✓ typecheck 1.82s", "✓ lint 0.94s", "✓ duplication 2.10s", + "StateDot.tsx 100% 100% 100% -", "markdown/Markdown.tsx 100% 100% 100% -", "", "1 of 4 checks failed", - "[exit code: 1]", ], "prompt": [ "nested pnpm run check", @@ -298,7 +298,7 @@ it('the chat card expands the collapsed middle in place, without opening the det { "cappedLines": 8, "detailsOpen": false, - "expandedLines": 22, + "expandedLines": 21, "expanderLabel": "收起输出", } `) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e649abc9e7..e5a235973f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -97,8 +97,11 @@ function sgr(code: number, body: string): string { * 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 * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the - * height cap collapses the middle, and the trailing `[exit code: N]` marker the - * bash tool appends, from which the exit pill is recovered. + * height cap collapses the middle. The exit status is authored separately in + * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash + * presenter CONSUMES its `[exit code: N]` marker out of the body, because a + * terminal card shows the exit as its own pill and leaving the marker in would + * render it twice (packages/bash/tool-bash/src/render.ts). */ const TERMINAL_OUTPUT_FIXTURE = [ sgr(1, 'Running 4 checks'), @@ -122,7 +125,6 @@ const TERMINAL_OUTPUT_FIXTURE = [ 'markdown/Markdown.tsx 100% 100% 100% -', '', sgr(31, '1 of 4 checks failed'), - '[exit code: 1]', ].join('\n') /** diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index d5fa1ea34f..8b7cede2fc 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -7,13 +7,18 @@ .block { --dsl-terminal-radius: 12px; --dsl-terminal-line-height: 22px; - /* Reserved strip to the left of the card for the per-line run-state dots. - The dots sit outside the card surface, so a reader scans command state - down one column without the dots competing with the commands themselves. */ + /* The card's own left inset, holding the run-state dot in a column of its own + so it never competes with the commands for horizontal space. */ --dsl-terminal-gutter: 30px; position: relative; - margin: 16px 0 16px var(--dsl-terminal-gutter); + margin: 16px 0; + /* The gutter is the card's OWN padding, not a margin: every consumer rewrites + `margin` wholesale (each render site sets its own indent), which silently + cancelled the reservation and let the dot fall outside the card into a + container that clips it. Owning the reservation here keeps the invariant + with the component that depends on it. */ + padding-left: var(--dsl-terminal-gutter); color: var(--dsw-alias-label-primary); background: var(--dsw-alias-markdown-code-block); border-radius: var(--dsl-terminal-radius); @@ -25,7 +30,9 @@ display: flex; align-items: flex-start; gap: 12px; - padding: 9px 14px; + /* 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; background: var(--dsw-alias-markdown-code-block-banner); border-top-left-radius: var(--dsl-terminal-radius); border-top-right-radius: var(--dsl-terminal-radius); @@ -50,12 +57,13 @@ line-height: var(--dsl-terminal-line-height); } -/* Out of flow in the gutter, so a dot neither indents its command nor depends - on the command's own text metrics to line up with it. Centered against the - row's line box rather than sitting on the code font's baseline. */ +/* Out of flow inside the card's own gutter padding, so the reservation and the + dot move together and no consumer margin can pull them apart; the dot neither + indents its command nor depends on the command's text metrics to line up. + Centered against the row's line box, not the code font's baseline. */ .runState { position: absolute; - left: calc(-1 * var(--dsl-terminal-gutter)); + left: calc(-1 * var(--dsl-terminal-gutter) + 8px); top: 50%; transform: translateY(-50%); } @@ -103,7 +111,7 @@ } .output { - padding: 12px 14px; + padding: 12px 14px 12px 0; font: var(--dsw-font-markdown-code-block); overflow-x: auto; overflow-y: hidden; @@ -132,7 +140,7 @@ } .empty { - padding: 12px 14px; + padding: 12px 14px 12px 0; font: var(--dsw-font-markdown-code-block); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 0932f4a7f0..1d56050b3c 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -154,7 +154,12 @@ export function TerminalBlock({ const body = command.endsWith('\n') ? command.slice(0, -1) : command return body.split('\n') }, [command]) - const empty = text.trim() === '' + // Read from the parsed lines the card actually renders, not from the raw text: + // output that is only escapes or control bytes (a lone reset, an OSC title, an + // erase) survives `text.trim()` yet parses to nothing visible. Judging it on + // the raw text drew an output box of blank rows plus a copy control for + // invisible bytes, and hid the placeholder that belongs there. + const empty = lines.every(line => line.every(span => span.text.trim() === '')) const hidden = lines.length - maxLines const capped = hidden > 0 && !expanded // Same split arithmetic as the TUI transcript's collapsed tool card, so a diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts index 1d7dd87796..57c49300f0 100644 --- a/packages/client/ui-primitives/src/ansi.ts +++ b/packages/client/ui-primitives/src/ansi.ts @@ -87,6 +87,33 @@ const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g */ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g +/** Terminal tab stop width; a tab advances to the next multiple of this. */ +const TAB_WIDTH = 8 + +/** + * Characters a terminal advances two columns for: CJK scripts, fullwidth forms, + * CJK punctuation, and the emoji/symbol blocks a command's output realistically + * carries. + */ +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]', + 'u', +) + +/** + * Whether a character occupies two terminal columns (CJK, fullwidth forms, + * emoji). Covers the ranges a command's output realistically carries; a + * narrower guess would misalign the columns this card exists to preserve. + * @param char - one character from the output. + * @returns true when the terminal advances two columns for it. + */ +function isWide(char: string): boolean { + const code = char.codePointAt(0) + if (code === undefined || code < 0x1100) return false + return WIDE_CHAR.test(char) +} + /** * 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 @@ -104,42 +131,70 @@ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g * @param line - one output line, still carrying its CSI sequences. * @returns the line as the terminal would have it after every movement. */ -function replayLine(line: string): string { +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 + 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 }[] = [] + const columns: ({ sgr: string; char: string } | 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. - let sgr = '' + // 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 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 } + if (char === '\t') { + // A tab advances to the next 8-column stop, leaving the cells it skips + // as they were — which is how a redraw can leave a tabbed column + // standing. Column alignment is the whole point of this card. + const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH) + for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, 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++ } } } for (const match of line.matchAll(csi)) { 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 + // Both groups are mandatory in the pattern, so destructuring types them as + // strings without a fallback that could never run. + const params = String(match[1]) + const final = String(match[2]) + 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 + 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. + if (final !== 'm') continue + sgr = /^0?$/.test(params) ? '' : sgr + match[0] } consume(line.slice(at)) - // 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. + // 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. let out = '' - let active = '' - for (const column of columns) { + let active = entrySgr + for (const slot of columns) { + const column = slot ?? { sgr: '', char: ' ' } if (column.sgr !== active) { if (active !== '') out += '\u001b[0m' out += column.sgr @@ -147,21 +202,35 @@ function replayLine(line: string): string { } out += column.char } - return active === '' ? out : `${out}\u001b[0m` + // 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 } } /** * 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. + * themselves. SGR state threads across lines: a newline does not reset it, so a + * run opened before a redraw still colors the lines after it. * @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') + const replayed: string[] = [] + let sgr = '' + 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(/\r|\u0008|\u001b\[[0-9]*K/.test(line) ? result.text : line) + sgr = result.sgr + } + 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 801484d6a7..e73d741167 100644 --- a/packages/client/ui-primitives/tests/ansi.spec.ts +++ b/packages/client/ui-primitives/tests/ansi.spec.ts @@ -249,6 +249,72 @@ describe('parseAnsiLines: backspaces', () => { }) }) +describe('parseAnsiLines: erase and column arithmetic', () => { + it('erases the rest of the line, the fixed companion of a redraw', () => { + // Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and + // progress bar writes `\r\x1b[K`; without the erase the previous frame's tail + // stands and the card shows text the terminal never displayed. + expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined }) + // The parameterless form and `0` are the same erase. + expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined }) + }) + + it('erases the whole line for the 2K form and to the cursor for 1K', () => { + expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined }) + // 1K clears left of the cursor without moving it, so those columns read as + // blanks — verified in a real terminal, which shows ` |` for this input. + expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: ' |', style: undefined }) + }) + + it('paints columns a 2K dropped as blanks when a later write lands past them', () => { + // 2K clears the line but leaves the cursor where it was, so writing there + // leaves the columns before it unwritten — blanks, as a terminal shows. + expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: ' x', style: undefined }) + }) + + it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => { + // Verified in a real terminal: `a\tb\rXY` shows `XY b` — the `b` sits at + // column 8, which a two-character redraw cannot reach. Counting the tab as + // one column would have produced `XYb` and destroyed the alignment. + expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY b', style: undefined }) + }) + + it('counts a wide character as the two columns a terminal advances', () => { + // `中` occupies two cells, so a two-character redraw covers exactly it. + expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined }) + }) + + it('does not accumulate a cursor or erase sequence into a cell style', () => { + // Only SGR carries graphic state. An erase folded into the style string + // would grow it per redraw and emit boundaries anser has to discard. + expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[ + { text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } }, + ]]) + }) +}) + +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. + // A newline does not reset the graphic state, so a replayed line must hand + // its state to the next one instead of closing it off. + expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([ + [{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }], + [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }], + ]) + }) + + it('tracks state through a line that needs no replay', () => { + // The middle line has no movement, so it is not replayed — but its own SGR + // still has to reach the line after it. + expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([ + [{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + [{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + [{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + ]) + }) +}) + describe('parseAnsiLines: runs spanning lines', () => { it('carries one run\'s style onto every line it covers', () => { expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([ diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx index d1162fcb7f..6b712414c5 100644 --- a/packages/client/ui-primitives/tests/terminal-block.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -124,6 +124,17 @@ describe('TerminalBlock states', () => { expect(screen.getByText('无输出')).toBeTruthy() }) + it('treats output that renders nothing visible as empty', () => { + // A lone reset, an OSC title, an erase: all survive `text.trim()` yet parse + // to nothing. Judging emptiness on the raw text drew a box of blank rows + // plus a copy control for invisible bytes, and hid the placeholder. + const view = render() + expect(view.getByText('无输出')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + view.rerender() + expect(view.getByText('无输出')).toBeTruthy() + }) + it('merges className onto the wrapper', () => { const view = render() expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)