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 new file mode 100644 index 0000000000..347d5978c0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 76d5c47054378330da9e9eebb70571925e47f741 +2026-07-28-web-terminal-card.zh.md: 83d5e7f72d9b04358ce4fe1fd9295e5c97045031 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 new file mode 100644 index 0000000000..76d5c47054 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -0,0 +1,65 @@ +# Agent Note: Web terminal card — the bash render intent reaches the browser + +Status: implemented + +English | [中文](2026-07-28-web-terminal-card.zh.md) + +## Problem + +The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as a `$`-prompt card with an exit line and a head/tail height cap. + +The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
+
+## Decision
+
+`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering.
+
+The component's contract:
+
+- **Prompt line.** A shortened cwd label followed by the command verbatim. 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 `$`.
+- **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, and a carriage return reduces its line to the final redraw, which is what a terminal shows for progress output.
+- **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.
+
+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.
+
+### Inline output in the chat row reverses a stated convention
+
+`chat/ToolRow.tsx` and `contract/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
+
+The reason the reversal holds: for a shell command the output *is* the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.
+
+The remaining bound: the row caps at `CHAT_TERMINAL_MAX_LINES` (8), half the primitive's default, which the panel keeps — the message flow is a summary surface read across many calls, the panel is the single-call reading surface, so the panel stays the place for the full output. Only the terminal intent renders inline; a generic tool's content is still panel-only.
+
+## Alternatives considered
+
+**Render the terminal block only in the details panel.** This keeps the stated no-inline-output convention and needs no reversal to record. Rejected by the owner's explicit decision: a shell command's output is what the user came to read, and putting it one click away costs more than the convention buys. Recorded here as the owner's call, not as a conclusion derived from the codebase.
+
+**Reuse `CodeBlock` with a `console` language instead of a new primitive.** Rejected: `CodeBlock` soft-wraps, which is the defect being fixed, and it has no exit status, no cwd prompt line, no height cap, and no ANSI handling. Adding four terminal-specific concerns to the shared code-fence component would impose them on every markdown fence. The two components share their geometry and font tokens instead, which is the only part where one implementation is correct for both.
+
+**Hand-roll the SGR parser.** Rejected: an SGR parser is exactly the surface [prefer maintained dependencies over hand-rolling](../process/2026-07-26-dependencies-over-hand-rolling.md) says not to own — its edge cases (256-palette and truecolor forms, `reverse`, multi-parameter runs, unterminated sequences) each fail on output nobody produces in a test, so a hand-rolled version stays subtly wrong for a long time. Stated honestly against that policy's bar: `anser` does **not** delete existing owned code. It is a capability addition, which that note distinguishes from a net-deletion simplification; the health and boundary-fit halves of the bar are what it clears. What stays hand-rolled is the part `anser` does not cover: the theme-token color mapping, the non-CSI sanitizing, the carriage-return redraw, and the per-line span folding the height cap slices.
+
+## Consequences
+
+`anser` is a new runtime dependency of `packages/client/ui-primitives`, so every consumer of that package pays for it once. A bash row in the Web chat carries output, which is a deliberate density increase over a summary-only row; the cap is what keeps it bounded, and a tighter cap is a props change, not a redesign.
+
+`TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed.
+
+Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own.
+
+## 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 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, 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 chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card, 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 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes; 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/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, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a full consumer of the `terminal` arm rather than of args alone.
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and its shiki arm, and records why tool output deliberately stays unhighlighted; ANSI color here is authored color, not guessed grammar.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
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
new file mode 100644
index 0000000000..83d5e7f72d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
@@ -0,0 +1,65 @@
+# Agent Note: Web terminal card — the bash render intent reaches the browser
+
+Status: implemented
+
+[English](2026-07-28-web-terminal-card.md) | 中文
+
+## Problem
+
+bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——TUI 也早已把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
+
+Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
+
+## Decision
+
+`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。
+
+该组件的契约:
+
+- **提示符行。** 一个缩短的 cwd 标签,其后原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。
+- **不软换行。** 输出行使用 `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;回车会把所在行归约为最后一次重绘,这正是终端对进度输出的呈现。
+- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
+
+几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。
+
+### 聊天行内嵌输出推翻了一条既有约定
+
+`chat/ToolRow.tsx` 与 `contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
+
+这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
+
+余下的约束:行内上限为 `CHAT_TERMINAL_MAX_LINES`(8),是组件默认值的一半,而面板沿用默认值——消息流是跨多次调用阅读的摘要表面,面板才是单次调用的阅读表面,因此面板仍是查看完整输出的地方。只有 terminal 意图内嵌渲染;generic 工具的内容依旧只在面板中。
+
+## Alternatives considered
+
+**只在详情面板渲染终端块。** 这样保留既有的「不内嵌输出」约定,也不需要记录任何推翻。已被 owner 的明确决定否决:shell 命令的输出正是用户来读的东西,把它挪到一次点击之外,代价高于该约定带来的收益。此处记录的是 owner 的裁决,而非从代码库推导出的结论。
+
+**复用 `CodeBlock` 并传入 `console` 语言,而不新建组件。** 已否决:`CodeBlock` 会软换行,而软换行正是本次要修的缺陷,且它没有退出状态、没有 cwd 提示符行、没有高度上限、也不处理 ANSI。把四项终端专属关注点加进共享的代码围栏组件,等于把它们强加给每一个 markdown 围栏。两个组件改为共享几何与字体 token,那是唯一一处「一套实现对两者都正确」的部分。
+
+**手写 SGR 解析器。** 已否决:SGR 解析器恰是[优先采用维护良好的依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.md)所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、`reverse`、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛如实陈述:`anser` **并未**删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它清过的是健康度与边界契合这两半门槛。`anser` 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
+
+## Consequences
+
+`anser` 成为 `packages/client/ui-primitives` 的一项新运行时依赖,因此该包的每个消费方都为它支付一次。Web 聊天中的 bash 行现在承载输出,相比只有摘要的行,这是有意提高的信息密度;上限是维持其有界的机制,而调紧上限是改一个 prop,不是重新设计。
+
+`TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。
+
+内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。
+
+## 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-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
+
+`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。
+
+`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md)——本次消费的 `card` 标签词汇;Web client 现在是 `terminal` 分支的完整消费方,而不再只消费参数。
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md)——它拥有 `CodeBlock` 及其 shiki 分支,并记录了工具输出为何有意不做语法高亮;这里的 ANSI 颜色是作者指定的颜色,不是猜出来的语法。
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md)——两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
index 9fd37bcedb..72cbbc368f 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # 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
-2026-07-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
-2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+2026-07-26-web-syntax-highlighting-shiki.md: 48a1e4c43f19693f90906f210f0ed85db3f31687
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 780b66a309c841c542f873f376226b8da454e050
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
index b329e35f1d..48a1e4c43f 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
@@ -17,7 +17,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
 - **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
 - **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
 - **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
-- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.
+- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Tool output is never syntax-highlighted — it is arbitrary text, and guessing a grammar would mis-highlight more than it helps; a bash card's output carries only the color its own ANSI sequences declare, through [the terminal card](../feature/2026-07-28-web-terminal-card.md).
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
index 8e9d1f0d0c..780b66a309 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
@@ -17,7 +17,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
 - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
 - **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
 - **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
-- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
+- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。
 
 ## 曾考虑的替代方案
 
diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts
index f53777fff8..101859cc4a 100644
--- a/apps/web/tests/code-mode-fixture.snapshot.ts
+++ b/apps/web/tests/code-mode-fixture.snapshot.ts
@@ -213,9 +213,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
   }).toMatchInlineSnapshot(`
     {
       "subCells": [
-        "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
-        "#52Subread · {"path":"notes/demo.txt"}+0.8s",
-        "#53Subread · {"path":"notes/missing.txt"}+0.8s",
+        "#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
+        "#50Subread · {"path":"notes/demo.txt"}+0.8s",
+        "#51Subread · {"path":"notes/missing.txt"}+0.8s",
       ],
     }
   `)
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index bbae7363df..b0e340da43 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -26,6 +26,7 @@ const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
 const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
+const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'navigation-panes-web-e2e'
 
@@ -170,9 +171,11 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await bashRow.click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
     // The open panel shows the selected call's name, arguments, and durable
-    // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total).
+    // result. The chat row carries its own terminal card, so NAVIGATION_OK
+    // appears there too — in the prompt line and in the output.
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
-    // Golden of the open panel: tool name header, Input args, Output result.
+    // Golden of the open panel: tool name header, Input args, and the Output
+    // section's terminal card (prompt line + captured output).
     const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
@@ -180,11 +183,50 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
+    await page.getByRole('tab', { name: 'Chat' }).click()
+    // The card is resident in the keyed bash row (no expand gesture): the
+    // recorded command's own output sits in the message flow, derived from the
+    // logged call/result presentations alone.
+    const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
+    await card.waitFor({ timeout: 15_000 })
+    // Real layout, not jsdom's stub (which computes no geometry at all):
+    // squeeze the output pane below its content width and the line must keep
+    // its single row and overflow sideways instead of folding. Soft-wrapping
+    // here is what shredded the column alignment this card exists to hold.
+    const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
+      const pane = node as HTMLElement
+      const row = pane.querySelector('[class*="_line_"]')
+      if (row === null) throw new Error('output pane has no line')
+      const before = row.offsetHeight
+      const restore = pane.style.width
+      pane.style.width = '8px'
+      const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
+      pane.style.width = restore
+      return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
+    })
+    expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
+    // Golden of the card at rest — captured before the copy click, whose
+    // confirmation label self-reverts on a timer and would not hold still.
+    const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
+    // Copy writes the raw output through the browser's own clipboard, which in
+    // a real page is the async Clipboard API rather than the jsdom fallback.
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await card.locator('[class*="_copyButton_"]').first().click()
+    await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
+      .toBe('复制成功')
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
+      'terminal-card.expected.md',
     ])
   })
 })
diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
index d69a95eb2d..9eee71468d 100644
--- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
+++ b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
@@ -1,5 +1,8 @@
 - text: bash
 - button "关闭详情"
-- text: Input
+- text: Input json
+- button "复制"
 - code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
-- text: Output NAVIGATION_OK
+- text: Output $ echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK
diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
new file mode 100644
index 0000000000..2b81725468
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
@@ -0,0 +1,3 @@
+- text: $ echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK
diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts
new file mode 100644
index 0000000000..53215f4ba9
--- /dev/null
+++ b/apps/web/tests/terminal-card.snapshot.ts
@@ -0,0 +1,324 @@
+// @vitest-environment jsdom
+// Terminal card snapshot over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session and pins the `card: 'terminal'` render
+// intent at both of its conversation render sites, for both chat-row shapes:
+// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
+// turn 66's `bash` on the keyed BashRow registration (resident body), plus the
+// details panel's Output section. Turn 66 carries what turn 60's three plain
+// lines cannot — SGR runs resolved to --dsw-* tokens, output past the chat
+// cap, a nested cwd, and a non-zero exit pill.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  {
+    id: '@deepseek-ai/dsh-client-ui-workspace',
+    dir: 'ui-workspace',
+    url: '/plugins/ui-workspace.js',
+    rev: 'fx',
+    inject: [
+      '@deepseek-ai/dsh-client-runtime',
+      '@deepseek-ai/dsh-client-ui-conversation',
+      '@deepseek-ai/dsh-client-ui-sidebar',
+    ],
+  },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+  plugin.url,
+  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+  __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+  observe(): void {}
+  disconnect(): void {}
+  unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+  localStorage.clear()
+  document.title = 'DeepSeek Harness'
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+    setTimeout(() => { callback(0) }, 0) as unknown as number)
+  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+  act(() => { unmount?.() })
+  unmount = undefined
+  cleanup()
+  delete win.__DSH_BOOT__
+  delete win.__ModuleLoader__
+  document.body.innerHTML = ''
+  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+  document.title = ''
+  history.replaceState(null, '', '/')
+  vi.unstubAllGlobals()
+})
+
+/** Boot the complete built client graph against the populated fixture branch. */
+function boot(): void {
+  history.replaceState(null, '', '/?fixture')
+  const root = document.createElement('div')
+  root.id = 'root'
+  document.body.appendChild(root)
+  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+  act(() => {
+    const entry = new AppWebEntry(root, {
+      fetchBundle: (url) => {
+        const code = bundles.get(url)
+        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+      },
+      executeBundle: (code) => { (0, eval)(code) },
+    })
+    void entry.run()
+    unmount = () => { entry.dispose() }
+  })
+}
+
+/** Collapse decorative whitespace while preserving the text a user sees. */
+function visibleText(element: Element): string {
+  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+/**
+ * Read one terminal card's user-visible state. Output lines keep their interior
+ * whitespace: holding column alignment is what this card exists for, so
+ * collapsing runs of spaces would hide the behavior under test.
+ */
+function readCard(card: Element) {
+  const status = card.querySelector('[class*="_status_"]')
+  const expander = card.querySelector('button[aria-expanded]')
+  return {
+    prompt: `${card.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${card.querySelector('[class*="_command_"]')?.textContent ?? ''}`,
+    status: status === null ? null : status.textContent,
+    copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
+    lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
+    expander: expander === null ? null : {
+      label: expander.getAttribute('aria-label'),
+      text: expander.textContent,
+      expanded: expander.getAttribute('aria-expanded'),
+    },
+    // Every color the ANSI parser emits resolves through a --dsw-* token, so
+    // the card follows the theme instead of painting literal terminal rgb.
+    colors: [...new Set([...card.querySelectorAll('span[style]')]
+      .map(span => span.getAttribute('style')))],
+  }
+}
+
+/** Open the fixture history session (the alpha log carrying both bash turns) and wait for its tail. */
+async function openFixtureSession(): Promise {
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  // Anchor on the expandable Workspace group row: the title and the blank
+  // session row can both read "fixture".
+  const group = (await within(tree).findAllByText('fixture'))
+    .map(el => el.closest('[role="treeitem"]'))
+    .find(el => el?.getAttribute('aria-expanded') !== null)
+  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+  if (group.getAttribute('aria-expanded') === 'false') {
+    fireEvent.click(within(group).getByText('fixture'))
+    await waitFor(() => {
+      expect(group.getAttribute('aria-expanded')).toBe('true')
+    })
+  }
+  fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+  await waitFor(() => {
+    expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
+  }, { timeout: 10_000 })
+}
+
+/** The keyed BashRow of fixture turn 66 (the one carrying the ANSI sample). */
+function keyedBashRow(): Element {
+  const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
+    .find(node => visibleText(node).includes('pnpm run check'))
+  if (row === undefined) throw new Error('keyed bash row for turn 66 missing')
+  return row
+}
+
+/** The turn-60 fallback row, which reaches the terminal card through GenericToolCard/ToolRow. */
+function fallbackBashRow(): Element {
+  const row = document.querySelector('[data-tool="fx-bash"]')
+  if (row === null) throw new Error('fx-bash fallback row missing')
+  return row
+}
+
+it('renders the keyed bash row with a resident terminal card', async () => {
+  boot()
+  await openFixtureSession()
+
+  const row = keyedBashRow()
+  const card = row.parentElement?.querySelector('[data-terminal]')
+  if (card === null || card === undefined) throw new Error('keyed bash row has no resident terminal card')
+  // The prompt shortens the nested cwd to its last segment, the exit pill
+  // recovers the trailing marker's code, ANSI runs land on theme tokens, and
+  // the chat cap (8) collapses the middle into a head/tail split with an
+  // expander between them.
+  expect(readCard(card)).toMatchInlineSnapshot(`
+    {
+      "colors": [
+        "font-weight: 700;",
+        "color: var(--dsw-alias-state-success-primary);",
+        "color: var(--dsw-alias-state-error-primary);",
+      ],
+      "copy": "复制",
+      "expander": {
+        "expanded": "false",
+        "label": "展开其余 14 行输出",
+        "text": "… 其余 14 行",
+      },
+      "lines": [
+        "Running 4 checks",
+        "✓ typecheck                                          1.82s",
+        "✓ lint                                               0.94s",
+        "✓ duplication                                        2.10s",
+        "markdown/Markdown.tsx       100%     100%        100%         -",
+        "",
+        "1 of 4 checks failed",
+        "[exit code: 1]",
+      ],
+      "prompt": "nested pnpm run check",
+      "status": "退出码 1",
+    }
+  `)
+})
+
+it('the fallback row reaches the same card through its expand control', async () => {
+  boot()
+  await openFixtureSession()
+
+  const row = fallbackBashRow()
+  expect(row.querySelector('[data-terminal]')).toBeNull()
+  const toggle = row.querySelector('button[aria-expanded]')
+  if (toggle === null) throw new Error('fallback row expand control missing')
+  fireEvent.click(toggle)
+  const card = await waitFor(() => {
+    const found = row.querySelector('[data-terminal]')
+    if (found === null) throw new Error('terminal card missing after expanding the fallback row')
+    return found
+  })
+  // Three plain lines under the cap: no ANSI spans, no exit pill, no expander.
+  expect(readCard(card)).toMatchInlineSnapshot(`
+    {
+      "colors": [],
+      "copy": "复制",
+      "expander": null,
+      "lines": [
+        "total 2",
+        "drwxr-xr-x fixture",
+        "-rw-r--r-- demo.txt",
+      ],
+      "prompt": "fixture ls -la",
+      "status": null,
+    }
+  `)
+})
+
+it('the chat card expands the collapsed middle in place, without opening the details panel', async () => {
+  boot()
+  await openFixtureSession()
+
+  const card = keyedBashRow().parentElement?.querySelector('[data-terminal]')
+  if (card === null || card === undefined) throw new Error('resident terminal card missing')
+  const expander = card.querySelector('button[aria-expanded]')
+  if (expander === null) throw new Error('height-cap expander missing')
+  const capped = card.querySelectorAll('[class*="_line_"]').length
+
+  fireEvent.click(expander)
+  await waitFor(() => {
+    expect(card.querySelector('button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true')
+  })
+  expect({
+    cappedLines: capped,
+    expandedLines: card.querySelectorAll('[class*="_line_"]').length,
+    expanderLabel: card.querySelector('button[aria-expanded]')?.getAttribute('aria-label'),
+    // The card sits outside the summary row's click target, so toggling it
+    // left the details panel shut.
+    detailsOpen: screen.queryByText('Input') !== null,
+  }).toMatchInlineSnapshot(`
+    {
+      "cappedLines": 8,
+      "detailsOpen": false,
+      "expandedLines": 22,
+      "expanderLabel": "收起输出",
+    }
+  `)
+})
+
+it('the details panel Output section renders the same call at full height', async () => {
+  boot()
+  await openFixtureSession()
+
+  fireEvent.click(keyedBashRow())
+  const label = await screen.findByText('Output')
+  const section = label.closest('section')
+  if (section === null) throw new Error('Output section missing')
+  const card = section.querySelector('[data-terminal]')
+  if (card === null) throw new Error('details panel Output is not a terminal card')
+
+  const chatLines = keyedBashRow().parentElement?.querySelectorAll('[class*="_line_"]').length ?? 0
+  expect({
+    ...readCard(card),
+    // The panel keeps the primitive's own allowance (16) against the chat
+    // row's 8, so it shows strictly more of the same output.
+    panelLines: card.querySelectorAll('[class*="_line_"]').length,
+    chatLines,
+  }).toMatchInlineSnapshot(`
+    {
+      "chatLines": 8,
+      "colors": [
+        "font-weight: 700;",
+        "color: var(--dsw-alias-state-success-primary);",
+        "color: var(--dsw-alias-state-error-primary);",
+        "color: var(--dsw-alias-label-tertiary);",
+      ],
+      "copy": "复制",
+      "expander": {
+        "expanded": "false",
+        "label": "展开其余 6 行输出",
+        "text": "… 其余 6 行",
+      },
+      "lines": [
+        "Running 4 checks",
+        "✓ typecheck                                          1.82s",
+        "✓ lint                                               0.94s",
+        "✓ duplication                                        2.10s",
+        "✗ unit                                               8.41s",
+        "",
+        "packages/client/ui-primitives/tests/terminal-block.spec.tsx",
+        "  FAIL caps output at the configured line budget",
+        "CodeBlock.tsx               98.4%    96.2%       100%         41-43",
+        "highlight.ts                100%     100%        100%         -",
+        "Pill.tsx                    100%     100%        100%         -",
+        "StateDot.tsx                100%     100%        100%         -",
+        "markdown/Markdown.tsx       100%     100%        100%         -",
+        "",
+        "1 of 4 checks failed",
+        "[exit code: 1]",
+      ],
+      "panelLines": 16,
+      "prompt": "nested pnpm run check",
+      "status": "退出码 1",
+    }
+  `)
+})
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 5c7befd8c9..401d3a76a4 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -46,6 +46,51 @@ const MARKDOWN_FIXTURE = [
 
 const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
 
+/**
+ * SGR wrapper for the terminal output sample below: authoring the escapes as
+ * `\u001b` keeps literal control bytes out of this source file.
+ * @param code - the SGR parameter (an ANSI color or attribute number).
+ * @param body - the text the attribute applies to.
+ * @returns the body wrapped in the attribute and a reset.
+ */
+function sgr(code: number, body: string): string {
+  return `\u001b[${code}m${body}\u001b[0m`
+}
+
+/**
+ * Terminal output sample for fixture turn 66, authored to carry every feature
+ * the terminal card draws that turn 60's three plain lines cannot reach:
+ * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
+ * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
+ * 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.
+ */
+const TERMINAL_OUTPUT_FIXTURE = [
+  sgr(1, 'Running 4 checks'),
+  `${sgr(32, '\u2713')} typecheck                                          1.82s`,
+  `${sgr(32, '\u2713')} lint                                               0.94s`,
+  `${sgr(32, '\u2713')} duplication                                        2.10s`,
+  `${sgr(31, '\u2717')} unit                                               8.41s`,
+  '',
+  sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
+  `  ${sgr(31, 'FAIL')} caps output at the configured line budget`,
+  '    expected 16 lines, received 24',
+  '',
+  'NAME                        LINES    BRANCHES    FUNCTIONS    UNCOVERED',
+  'TerminalBlock.tsx           100%     100%        100%         -',
+  'ansi.ts                     100%     100%        100%         -',
+  'clipboard.ts                100%     100%        100%         -',
+  'CodeBlock.tsx               98.4%    96.2%       100%         41-43',
+  'highlight.ts                100%     100%        100%         -',
+  'Pill.tsx                    100%     100%        100%         -',
+  'StateDot.tsx                100%     100%        100%         -',
+  'markdown/Markdown.tsx       100%     100%        100%         -',
+  '',
+  sgr(31, '1 of 4 checks failed'),
+  '[exit code: 1]',
+].join('\n')
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -124,7 +169,8 @@ function buildAlphaLog(): SessionEvent[] {
   }
   // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
   // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
-  // stays presenter-less as the unknown fallback.
+  // stays presenter-less as the unknown fallback. Turn 66 is the second terminal sample, carrying
+  // what turn 60's three plain lines cannot (see TERMINAL_OUTPUT_FIXTURE) through the keyed row.
   const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
     const callId = `fx-call-${turn}`
     push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -201,6 +247,13 @@ function buildAlphaLog(): SessionEvent[] {
   const callIndex = events.length - 4
   const callTime = events[callIndex]?.time as number
   events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } })
+  // Turn 66: the terminal sample turn 60's three clean lines 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 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.
+  toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
   events.forEach((e, i) => { e.seq = i })
   return events as unknown as SessionEvent[]
 }
@@ -219,7 +272,11 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
     return undefined
   }
   switch (name) {
+    // Both names present the same terminal card: `fx-bash` lands on the
+    // render-site fallback row, `bash` on the keyed BashRow registration, so
+    // the two chat-row shapes of one render intent are both reachable.
     case 'fx-bash':
+    case 'bash':
       return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
     case 'fx-write':
       return {
@@ -235,12 +292,24 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
   }
 }
 
+/**
+ * Recover the exit status from a trailing `[exit code: N]` marker, mirroring
+ * the real bash tool's `parseExitStatus` (this package must not depend on a
+ * tool package, so the marker contract is re-read rather than imported).
+ * @param text - the rendered result text.
+ * @returns the recovered exit code (0 when the marker is absent).
+ */
+function fixtureExitCode(text: string): number {
+  const marker = /\n\[exit code: (\d+)\]$/.exec(text)
+  return marker?.[1] === undefined ? 0 : Number(marker[1])
+}
+
 function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
   const call = presentCall(name, argsRaw)
   if (call === undefined) return undefined
   switch (call.card) {
     case 'terminal':
-      return { card: 'terminal', output: resultText, exitCode: 0 }
+      return { card: 'terminal', output: resultText, exitCode: fixtureExitCode(resultText) }
     case 'diff':
       return { card: 'diff', diffs: call.diffs }
     case 'generic':
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index c1a6828d5b..dd28591b01 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/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-conversation/README.md
-README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739
-README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
+README.md: 1c6912b05e259fa1f4a7096c3a2b82f9f67f5527
+README.zh.md: 157bfafd3a1157420acbb73861cd40d759228743
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 56a445ccfa..1c6912b05e 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
 
 Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
 
+A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
+
 Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
 
 The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks ·  in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index a7c160ecdd..157bfafd3a 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -10,6 +10,8 @@
 
 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
 
+声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
+
 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 
 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks ·  in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index 5cb2126f34..0bfde97019 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -9,6 +9,7 @@ import {
   IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowOwnerProps } from '../contract/slots.ts'
+import { terminalCardModel } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
 import { ToolRow } from './ToolRow.tsx'
 import { IconSparkle16 } from './IconSparkle16.tsx'
@@ -35,6 +36,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
       title={model.title}
       summary={model.summary}
       body={model.body}
+      terminal={terminalCardModel(block)}
       state={model.state}
       onOpenDetails={openDetails}
     />
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 5d44a9260e..d238218d97 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -102,9 +102,13 @@ button.leading {
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* The code variant's expanded body is the run_code program, rendered through
-   the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
-   this row's concern. */
-.codeBody {
+/* The two block-shaped expanded bodies: the code variant's run_code program
+   through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
+   command output through TerminalBlock. Both are drawn by the shared
+   primitive, so only the row's indentation is this file's concern — the margin
+   also replaces each primitive's own standalone vertical spacing with the
+   flow's row rhythm. */
+.codeBody,
+.terminalBody {
   margin: 4px 0 4px 22px;
 }
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index a81c084b8e..91dcfe4a0a 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -1,13 +1,19 @@
 // ToolRow: the single-line tool summary row (figma component set 122:9479) —
 // 16px leading slot (state dot / tool icon, chevron when expanded) + title +
-// separator dot + FILL-truncated summary. Expanded body is indented gray text;
-// no inline output (full results live in the details panel). Expand state is
-// component-local view state; row click hands the selection off to the owner.
+// separator dot + FILL-truncated summary. The collapsed row is always one
+// line; the expanded body is indented gray text, the run_code program through
+// CodeBlock, or — for a call whose render intent is a terminal card — the
+// command's own output through TerminalBlock, capped at
+// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. The details
+// panel remains the full-height reading surface for the same call. Expand
+// state is component-local view state; row click hands the selection off to
+// the owner.
 
 import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
 import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
 import css from './ToolRow.module.css'
 
@@ -19,8 +25,15 @@ export interface ToolRowProps {
   icon: ReactNode
   title: string
   summary: string
-  /** Expanded-body text; null = not expandable (leading slot never toggles). */
+  /** Expanded-body text; null = no text body (`terminal` is the other body source). */
   body: string | null
+  /**
+   * Terminal-card material for a call whose render intent is a terminal card
+   * (derived by `terminalCardModel`); it replaces the text body when present.
+   * Null or absent leaves the text body, and a row with neither is not
+   * expandable (its leading slot never toggles).
+   */
+  terminal?: TerminalCardModel | null | undefined
   state: ToolRowState
   /** Makes the row itself the expand control instead of only its leading icon. */
   expandOnRowClick?: boolean | undefined
@@ -46,12 +59,18 @@ export function ToolRow({
   title,
   summary,
   body,
+  terminal,
   state,
   expandOnRowClick = false,
   onOpenDetails,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
-  const expandable = body !== null
+  const terminalBody = terminal ?? null
+  const expandable = body !== null || terminalBody !== null
+  // The text arms take the empty string for a null body: a row expandable
+  // only through its terminal material renders the terminal body instead, so
+  // this substitution never shows.
+  const text = body ?? ''
   const open = expanded && expandable
   const rowExpands = expandable && expandOnRowClick
   const toggleExpand = () => {
@@ -99,9 +118,11 @@ export function ToolRow({
           
         )}
       
-      {open && (variant === 'code'
-        ? 
-        : 
{body}
)} + {open && (terminalBody !== null + ? + : variant === 'code' + ? + :
{text}
)} ) } diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts new file mode 100644 index 0000000000..a1b274e3f9 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -0,0 +1,83 @@ +/** + * Pure derivation of the terminal-card props from a frozen call slice: the + * `card:'terminal'` render intent the bash tool declares arrives on the + * snapshot as `callView`/`resultView`, and this is the one place that turns + * that pair into what {@link TerminalBlock} draws. Both conversation render + * sites (the chat tool row's expanded body and the details panel's Output + * section) call this, so the command, cwd, output and exit status they show + * are derived once. + * @module + */ +import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Output lines the chat row's expanded terminal body shows before collapsing + * the middle — half the primitive's own default, which the details panel + * keeps. A chat row is a summary surface inside the message flow: the flow + * must stay scannable across many calls, while the details panel is the + * single-call reading surface. A design constant of this UI's row geometry, + * not a deployment choice, so it is fixed here rather than a plugin Config + * field. + */ +export const CHAT_TERMINAL_MAX_LINES = 8 + +/** + * The {@link TerminalBlock} props this derivation owns. Picked off the + * primitive's props so the two stay in step; `home` is absent because the web + * client has no home path for the session host (a cwd renders as its last + * path segment), and `maxLines`/`className` belong to each render site. + */ +export type TerminalCardModel = Pick< + TerminalBlockProps, + 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running' +> + +/** + * Derive the terminal-card props for a tool call, or null when this call is + * not a terminal card and belongs on the generic path. + * + * The call side supplies the command and its working directory; the result + * side supplies the captured output and exit status. Three cases produce + * null, all of them the documented generic-card default: + * + * - Neither side declares `card:'terminal'` — including a `card` value this + * UI version does not know, which arrives over the wire and therefore + * cannot be trusted to be one of the compiled variants. + * - A settled call whose result view is not a terminal card: the result + * presentation decides how the settled call renders, and the bash tool + * returns a generic fenced card for an execution error or a background + * start, whose text and error styling the generic path preserves. + * + * Window truncation can drop the call head from a settled result (see + * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal + * result with no call side. That still renders: the command falls back to the + * result view's replacement title, then to an empty command (the prompt line + * draws bare), and the prompt shows no cwd. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the terminal-card props, or null for the generic path. + */ +export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | null { + const call = block.callView?.card === 'terminal' ? block.callView : null + if (!('kind' in block)) { + // Running: the call view exists, the result view does not yet. + return call === null ? null : { + command: call.title, + cwd: call.cwd, + output: undefined, + exitCode: undefined, + signal: undefined, + running: true, + } + } + const result = block.resultView?.card === 'terminal' ? block.resultView : null + if (result === null) return null + return { + command: call?.title ?? result.title ?? '', + cwd: call?.cwd, + output: result.output, + exitCode: result.exitCode, + signal: result.signal, + running: false, + } +} diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..0b1afa8060 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -1,7 +1,9 @@ /** * Pure row-model derivation for tool summary rows: variant classification, - * one-line summary and expanded-body text from the frozen call slice. No - * inline output ever — full results live in the details panel. + * one-line summary and expanded-body text from the frozen call slice. This + * derivation reads the call ARGUMENTS only; a call whose render intent is a + * terminal card gets its expanded body from the views instead, through + * `terminalCardModel` in terminal-card-model.ts. */ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer 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 abdece3e25..ef4173735e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -92,3 +92,9 @@ .code[data-error] { 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. */ +.terminal { + margin: 0; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 650a95f833..326e765044 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -1,47 +1,59 @@ // DetailsPanel, P-I minimal form: close button + the selected call's args and -// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in- -// trajectory are deferred (ledger). Reads the selection from the shared chat +// result — args as JSON, the result raw except for a terminal-card call, whose +// Output section is the command's terminal card. The three-段 Switch / +// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the +// selection from the shared chat // store (conversation writes, this panel reads — the cross-registration // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { terminalCardModel } from '../contract/terminal-card-model.ts' +import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' /** Full props composed by reference from the contract (automatic shares & injected share). */ export type DetailsPanelProps = DetailsSlotProps -/** Selected call material: resolved result node, or the in-flight running call's args. */ +/** + * Selected call material: the call's display name and args plus the frozen + * block slice it came from. `block` is a snapshot-cached reference, so the + * wrapper stays shallow-equal across unrelated snapshot frames; the settled / + * running split is read off it with the `'kind' in block` discrimination + * instead of duplicated as flags. + */ interface CallMaterial { name: string argsRaw: string | null - result: ToolResultNode | null - running: boolean + block: ToolCallBlock +} + +/** Material of a settled result node (native call or run_code sub-dispatch). */ +function settledMaterial(node: ToolResultNode, callId: string): CallMaterial { + return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node } +} + +/** Material of an in-flight call (native call or run_code sub-dispatch). */ +function runningMaterial(call: RunningToolCall): CallMaterial { + return { name: call.name, argsRaw: call.argsRaw, block: call } } function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { for (const node of s.nodes) { - if (node.kind === 'tool-result' && node.callId === callId) { - return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false } - } + if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId) } const open = s.runningCalls.find(c => c.callId === callId) - if (open !== undefined) { - return { name: open.name, argsRaw: open.argsRaw, result: null, running: true } - } + if (open !== undefined) return runningMaterial(open) // run_code sub-dispatches: the native call-block shapes, so a selected // sub-row resolves through the same material as a native call — the // settled ToolResultNode form, or the RunningToolCall form mid-flight. for (const subs of s.codeDispatches.values()) { for (const sub of subs) { if (sub.callId !== callId) continue - if ('kind' in sub) { - return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false } - } - return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true } + return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub) } } return null @@ -95,15 +107,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane )}
Output
- {/* materialFor invariant: result===null ⇔ running (a settled - material always carries its result node). */} - {material.result === null - ?
运行中…
- : ( -
-                        {renderResult(material.result)}
-                      
- )} +
)} @@ -112,6 +116,29 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane ) } +/** + * The Output section's body for the selected call. A terminal-card call — a + * shell command's call/result views — renders through the shared TerminalBlock + * at the primitive's own full height allowance, so column-aligned output keeps + * its alignment and scrolls sideways instead of folding. Every other call, and + * a running call with no terminal card yet, keeps the flattened text form. + * @param props.material - the selected call's material from {@link materialFor}. + * @returns the Output section's body element. + */ +function OutputBody({ material }: { material: CallMaterial }) { + const terminal = terminalCardModel(material.block) + if (terminal !== null) return + // A settled call always carries the result node the flattened form needs; + // the running shape has no result to flatten. + if (!('kind' in material.block)) return
运行中…
+ const result = material.block + return ( +
+      {renderResult(result)}
+    
+ ) +} + /** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */ function renderResult(node: ToolResultNode): string { const parts: string[] = [] diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..450eb2301d 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,4 +1,18 @@ -/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ +/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description), + plus the terminal card the row stacks under its summary line. */ + +/* Summary line over the terminal card; the summary row keeps its own 24px + height, so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.terminal { + margin: 4px 0 4px 22px; +} .root { display: flex; 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 616eee5943..49a5e6c2e4 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -3,10 +3,18 @@ // Product chrome matches ToolRow / Think (figma: Bash · {description}). // Child sessions keep a scoped badge so session-dimension differentiation stays // observable inside the component (no parallel registry). +// +// 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. import type { Context } from 'cordis' -import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './bash-sample.module.css' @@ -29,26 +37,37 @@ function stateStatus(state: ToolRowState): string | null { } } -/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ +/** + * Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the + * command's terminal card below it. The summary row keeps its own click target + * (the details handoff); the terminal card sits outside that row, so its copy + * and expand controls do not open the details panel. + */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) + const terminal = terminalCardModel(block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) const status = stateStatus(model.state) return ( -
- {leadingFor(model.state)} - {status !== null && {status}} - {isChild && scoped} - {model.title} - - {model.summary} +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {isChild && scoped} + {model.title} + + {model.summary} +
+ {terminal !== null && ( + + )}
) } diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 13a74548b4..4a5e80b224 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -71,6 +71,11 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', result({ call: null })).body).toBeNull() }) + it('a code row with an empty program falls back to the args JSON envelope', () => { + expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body) + .toBe('{\n "code": ""\n}') + }) + it('gives Cordis lifecycle tools action titles over their generic variants', () => { expect(toolRowModel('cordis_inspect', running({ name: 'cordis_inspect', @@ -139,6 +144,22 @@ describe('ToolRow', () => { expect(view.queryByTestId('tool-icon')).not.toBeNull() }) + it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => { + const view = render() + const row = view.getByRole('button') + fireEvent.keyDown(row, { key: 'Tab' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(row.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(row, { key: ' ' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + }) + + it('a non-expandable expandOnRowClick row exposes no row button', () => { + const view = render() + expect(view.queryByRole('button')).toBeNull() + }) + it('row click hands off to onOpenDetails; the expand toggle does not', () => { const open = vi.fn() const view = render() diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx new file mode 100644 index 0000000000..1ab75861b5 --- /dev/null +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -0,0 +1,359 @@ +// @vitest-environment jsdom +// The terminal render intent on the web side: the pure terminalCardModel +// derivation over callView/resultView, and both conversation render sites that +// consume it — the chat tool row's expanded body (GenericToolCard / BashRow) +// and the details panel's Output section. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { BashRow } from '../src/client/toolviews/bash-sample.tsx' + +afterEach(cleanup) + +/** + * Match an output line with its interior whitespace intact: the column + * alignment this card exists to preserve is exactly what the default + * whitespace-collapsing matcher would hide. + */ +const RAW = { normalizer: (text: string) => text } + +const SID = 's1' as SessionId + +const ARGS = '{"command":"ls -la","description":"List files"}' + +/** The bash tool's own call view for a foreground command. */ +const callTerminal = (over?: Partial>): ToolCallView => ({ + card: 'terminal', title: 'ls -la', description: 'List files', ...over, +}) + +/** The bash tool's own result view for a settled foreground command. */ +const resultTerminal = (over?: Partial>): ToolResultView => ({ + card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'bash', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'bash', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false, + callView: callTerminal(), resultView: resultTerminal(), ...over, +}) + +describe('terminalCardModel', () => { + it('derives a running card from the call view alone', () => { + expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({ + command: 'ls -la', cwd: '/projects/app', output: undefined, + exitCode: undefined, signal: undefined, running: true, + }) + }) + + it('derives a settled card from both sides, carrying the exit status', () => { + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '/projects/app' }), + resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }), + }))).toEqual({ + command: 'ls -la', cwd: '/projects/app', output: 'boom\n', + exitCode: 2, signal: undefined, running: false, + }) + expect(terminalCardModel(settled({ + resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, + }))?.signal).toBe('SIGTERM') + }) + + it('a window-truncated call side falls back to the result title, then to an empty command', () => { + // Truncation drops both the call head and its view (conversation.ts). + const truncated = { call: null, callView: null } + expect(terminalCardModel(settled({ + ...truncated, resultView: resultTerminal({ title: 'ls -la' }), + }))).toMatchObject({ command: 'ls -la', cwd: undefined, running: false }) + expect(terminalCardModel(settled(truncated))).toMatchObject({ command: '', cwd: undefined }) + }) + + it('returns null for every non-terminal call: no views, generic views, unknown cards', () => { + expect(terminalCardModel(running({ callView: null }))).toBeNull() + expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull() + expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() + // A generic result settles a terminal call as a generic card (the bash + // tool's own execution-error and background paths). + expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView + expect(terminalCardModel(running({ callView: future }))).toBeNull() + expect(terminalCardModel(settled({ + callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + }))).toBeNull() + }) +}) + +describe('chat row terminal body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({ + callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(), + }) + + it('the expanded body is the command output, capped tighter than the panel', () => { + expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the one-line summary row only, no output. + expect(view.getByText('List files')).toBeTruthy() + expect(view.queryByText(/a\.ts/)).toBeNull() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + expect(view.getByText('ls -la')).toBeTruthy() + // The args JSON body the generic path would have shown is gone. + expect(view.queryByText(/"command"/)).toBeNull() + }) + + it('the cap collapses a long output inside the row, expandable in place', () => { + const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`) + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('… 其余 3 行')).toBeTruthy() + expect(view.queryByText('line-5')).toBeNull() + fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' })) + expect(view.getByText('line-5')).toBeTruthy() + }) + + it('a running terminal call expands to the prompt line with no output yet', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('ls -la')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + }) + + it('a non-terminal call keeps the args-JSON text body', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText(/"command"/)).toBeTruthy() + }) + + it('a terminal call with no args still expands, through its terminal body alone', () => { + // Empty args make the text body null; the terminal material carries the row. + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + }) +}) + +describe('BashRow terminal card', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, + current: undefined, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode, openDetails = vi.fn()): ToolRowProps => ({ + callId: 'c1', toolName: 'bash', block, openDetails, + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the command output under the summary row, without an expand gesture', () => { + const openDetails = vi.fn() + const view = render() + expect(view.getByText('List files')).toBeTruthy() + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + // The terminal card sits outside the row's click target: copying does not + // open the details panel. + fireEvent.click(view.getByText('复制')) + expect(openDetails).not.toHaveBeenCalled() + fireEvent.click(view.getByText('List files')) + expect(openDetails).toHaveBeenCalledTimes(1) + }) + + it('a non-terminal bash call (background start) renders the summary row alone', () => { + const view = render() + expect(view.getByText('List files')).toBeTruthy() + expect(view.queryByText(/a\.ts/)).toBeNull() + }) +}) + +describe('DetailsPanel Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore( + { ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' } + + it('renders the terminal card at full height, keeping the JSON Input section', () => { + const long = Array.from({ length: 20 }, (_, i) => `row-${i}`) + const view = mount(snapshot({ + nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })], + }), target) + expect(view.getByText(/"command"/)).toBeTruthy() + expect(view.getByText('ls -la')).toBeTruthy() + // The panel takes the primitive's own default cap (16), not the row's. + expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy() + expect(view.getByText('row-0')).toBeTruthy() + }) + + it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.getByText('ls -la')).toBeTruthy() + expect(view.queryByText('运行中…')).toBeNull() + }) + + it('a running non-terminal call keeps the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target) + expect(view.getByText('运行中…')).toBeTruthy() + }) + + it('a non-terminal result keeps the flattened pre with its error styling', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, isError: true, + content: [{ type: 'text', text: 'permission denied' }], + })], + }), target) + const pre = view.container.querySelector('pre[data-error]') + expect(pre?.textContent).toBe('permission denied') + }) + + it('a run_code sub-dispatch resolves to its own terminal card', () => { + const view = mount(snapshot({ + codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]), + }), target) + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + }) + + it('a running run_code sub-dispatch resolves through the running material', () => { + const view = mount(snapshot({ + // The leading non-matching sub-call exercises the scan's skip. + codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]), + }), target) + expect(view.getByText('ls -la')).toBeTruthy() + }) + + it('a window-truncated call head titles the panel by callId and drops the Input section', () => { + const view = mount(snapshot({ + nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })], + }), target) + expect(view.getByText('c1')).toBeTruthy() + expect(view.queryByText('Input')).toBeNull() + expect(view.getByText('Output')).toBeTruthy() + }) + + it('scans past other nodes and other calls before reporting the call out of window', () => { + const view = mount(snapshot({ + nodes: [ + { kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] }, + settled({ callId: 'elsewhere' }), + ], + runningCalls: [running({ callId: 'also-elsewhere' })], + }), target) + expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() + }) + + it('no selection at all renders the guidance line and the default title', () => { + const view = mount(snapshot(), null) + expect(view.getByText('详情')).toBeTruthy() + expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy() + }) + + it('a step selection without a callId renders the guidance line too', () => { + const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 }) + expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy() + }) + + it('the close button reaches closeDetails', () => { + localStorage.clear() + const chat = createChatStore().create() + const closeDetails = vi.fn() + const snap = snapshot() + const view = render( + snap, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(createSnapshotStore( + { ids: [], byId: {}, current: undefined, phase: 'ready' }))} + useWorkspaces={bindSnapshotSelector(createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }))} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={closeDetails} + />, + ) + fireEvent.click(view.getByRole('button', { name: '关闭详情' })) + expect(closeDetails).toHaveBeenCalledTimes(1) + }) + + it('a non-text result block renders as JSON, and an empty result falls back to its error', () => { + const nonText = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'reasoning', text: 'why' }], + })], + }), target) + // Scope to the Output section: the Input section's CodeBlock renders a + //
 of its own, and it comes first in document order.
+    expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
+      .toBe('{\n  "type": "reasoning",\n  "text": "why"\n}')
+    cleanup()
+    const empty = mount(snapshot({
+      nodes: [settled({
+        callView: null, resultView: null, content: [], isError: true,
+        error: { name: 'ToolError', code: 'interrupted' },
+      })],
+    }), target)
+    expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
+  })
+})
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 6162494def..1eb8a05ba0 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # 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
-README.md: 58e450451ab64f69762817dfb277b8a888e2177f
-README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
+#   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
+README.md: c9c70f29804ac4e6783486595460bf07499e1dff
+README.zh.md: 254fc5ba5aef553fd447338353a0c5311ddbd98a
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 58e450451a..c9c70f2980 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -2,12 +2,16 @@
 
 English | [中文](README.zh.md)
 
-Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
+Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), TerminalBlock. Contract: api-contracts v3 §8.
 
 ## Markdown rendering
 
 `MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
 
+## Terminal output
+
+`TerminalBlock` renders a shell command as a terminal surface: a prompt line (shortened `cwd` label plus the command), 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. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; 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
 
 None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
@@ -21,3 +25,4 @@ None; this package neither assembles nor sends a provider request.
 - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
 - **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.
+- **`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.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 6824f3efe4..254fc5ba5a 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -2,12 +2,16 @@
 
 [English](README.md) | 中文
 
-纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。
+纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及 TerminalBlock。契约:api-contracts v3 §8。
 
 ## Markdown 渲染
 
 `MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
 
+## 终端输出
+
+`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签加命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
+
 ## 模型体验
 
 无。该包在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
@@ -21,3 +25,4 @@
 - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
+- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,而光标移动、清屏和备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 9ce2bc8676..e629626129 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -21,6 +21,7 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@shikijs/langs": "^4.3.1",
+    "anser": "^2.3.5",
     "clsx": "^2.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/packages/client/ui-primitives/src/Pill.tsx b/packages/client/ui-primitives/src/Pill.tsx
index 76b9301972..2c3c24c1ad 100644
--- a/packages/client/ui-primitives/src/Pill.tsx
+++ b/packages/client/ui-primitives/src/Pill.tsx
@@ -12,7 +12,9 @@ import css from './Pill.module.css'
  */
 export function Pill({ active = false, className, children, onClick, ...rest }: {
   active?: boolean
-  className?: string
+  // `| undefined` so a caller can forward an optional class straight through
+  // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
+  className?: string | undefined
   children?: ReactNode
 } & ButtonHTMLAttributes) {
   if (!onClick) {
diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css
new file mode 100644
index 0000000000..8a4f4a5e60
--- /dev/null
+++ b/packages/client/ui-primitives/src/TerminalBlock.module.css
@@ -0,0 +1,101 @@
+/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
+   markdown code-block font) so a terminal card and a fenced code block read as
+   one family. The one deliberate divergence: output keeps `white-space: pre`
+   and scrolls horizontally, because folding a column-aligned command's output
+   destroys its alignment. */
+
+.block {
+  --dsl-terminal-radius: 12px;
+  --dsl-terminal-line-height: 22px;
+
+  position: relative;
+  margin: 16px 0;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: var(--dsl-terminal-radius);
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 9px 14px;
+  background: var(--dsw-alias-markdown-code-block-banner);
+  border-top-left-radius: var(--dsl-terminal-radius);
+  border-top-right-radius: var(--dsl-terminal-radius);
+}
+
+/* The prompt row is the only element allowed to shrink; the status pill and
+   the copy control keep their intrinsic width. */
+.prompt {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  min-width: 0;
+  flex: 1;
+  font: var(--dsw-font-markdown-code-block);
+}
+
+.cwd {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.command {
+  min-width: 0;
+  color: var(--dsw-alias-label-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status {
+  flex: none;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.copyButton {
+  flex: none;
+  background-color: transparent;
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+  font: var(--dsw-font-xs-13);
+}
+
+.output {
+  padding: 12px 14px;
+  font: var(--dsw-font-markdown-code-block);
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+
+/* No wrapping, no word-break: alignment is the payload of terminal output. */
+.line {
+  min-height: var(--dsl-terminal-line-height);
+  white-space: pre;
+}
+
+.expand {
+  display: block;
+  width: 100%;
+  padding: 0;
+  border: none;
+  background-color: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+  font: inherit;
+  text-align: left;
+}
+
+.expand:hover {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.empty {
+  padding: 12px 14px;
+  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
new file mode 100644
index 0000000000..e224e2b1de
--- /dev/null
+++ b/packages/client/ui-primitives/src/TerminalBlock.tsx
@@ -0,0 +1,170 @@
+// TerminalBlock: the terminal surface for a shell command and its output —
+// prompt line (shortened cwd + command), ANSI-colored output, settled exit
+// status, and a copy control for the raw output. Output never soft-wraps:
+// column-aligned output (ls, tables, box drawing) keeps its alignment and
+// scrolls horizontally instead of folding. Colors resolve through --dsw-*
+// tokens; ANSI parsing lives in ansi.ts.
+
+import { useCallback, useMemo, useState } from 'react'
+import clsx from 'clsx'
+import { parseAnsiLines, type AnsiLine } from './ansi.ts'
+import { writeClipboard } from './clipboard.ts'
+import { Pill } from './Pill.tsx'
+import css from './TerminalBlock.module.css'
+
+/**
+ * Output lines shown before the height cap collapses the middle. Matches the
+ * TUI transcript's default tool-output budget so both front ends cut a long
+ * command's output at the same place.
+ */
+export const DEFAULT_TERMINAL_MAX_LINES = 16
+
+export interface TerminalBlockProps {
+  /** The command line, rendered verbatim after the prompt label. */
+  command: string
+  /** Working directory for the prompt label; absent renders a plain `$`. */
+  cwd?: string | undefined
+  /** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
+  home?: string | undefined
+  /** The command's output text; may contain ANSI escape sequences. */
+  output?: string | undefined
+  /** Settled exit code; a non-zero value renders the status pill. */
+  exitCode?: number | undefined
+  /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
+  signal?: string | undefined
+  /** The command is still running: the block shows the prompt line alone. */
+  running?: boolean | undefined
+  /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
+  maxLines?: number | undefined
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+/**
+ * Prompt label for a working directory: `~` for the home directory itself,
+ * otherwise the path's last segment (both separators accepted, trailing
+ * separators ignored), falling back to the path itself when it has no
+ * segment.
+ * @param cwd - the working directory path.
+ * @param home - absolute home directory, when the caller knows it.
+ * @returns the prompt label.
+ */
+function promptLabel(cwd: string, home: string | undefined): string {
+  const trimmed = cwd.replace(/[/\\]+$/, '')
+  if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
+  const segment = trimmed.split(/[/\\]/).pop()
+  return segment === undefined || segment === '' ? cwd : segment
+}
+
+/**
+ * Status pill text for a settled command, or undefined when the command
+ * settled cleanly (exit 0, no signal) and needs no pill — the same
+ * distinction the bash tool's own exit-status markers draw.
+ * @param exitCode - settled exit code, when known.
+ * @param signal - settled terminating signal name, when known.
+ * @returns the pill text, or undefined for a clean exit.
+ */
+function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
+  if (signal !== undefined) return `信号 ${signal}`
+  if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
+  return undefined
+}
+
+/**
+ * Render one parsed output line. Runs without SGR state render as bare text,
+ * so uncolored output carries no span wrappers.
+ * @param line - the line's styled runs.
+ * @returns the line's children.
+ */
+function renderLine(line: AnsiLine) {
+  return line.map((span, index) => span.style === undefined
+    ? span.text
+    : {span.text})
+}
+
+/**
+ * Render a shell command as a terminal surface.
+ * @param props - see {@link TerminalBlockProps}.
+ * @returns the terminal block element.
+ */
+export function TerminalBlock({
+  command,
+  cwd,
+  home,
+  output,
+  exitCode,
+  signal,
+  running = false,
+  maxLines = DEFAULT_TERMINAL_MAX_LINES,
+  className,
+}: 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])
+  const [expanded, setExpanded] = useState(false)
+  const [copied, setCopied] = useState(false)
+
+  const onCopy = useCallback(() => {
+    if (copied) return
+    // The raw output, never the rendered tree: the prompt line and the status
+    // pill are chrome the user did not run.
+    void writeClipboard(text).then((ok) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => { setCopied(false) }, 1000)
+    })
+  }, [copied, text])
+
+  const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
+
+  const status = statusText(exitCode, signal)
+  const empty = 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
+  // command's head and tail slices agree between the two front ends.
+  const headLines = Math.ceil(maxLines / 2)
+  const tailLines = maxLines - headLines
+
+  return (
+    
+
+
+ {cwd === undefined ? '$' : promptLabel(cwd, home)} + {command} +
+ {status !== undefined && {status}} + {!running && !empty && ( + + )} +
+ {!running && (empty + ?
无输出
+ : ( +
+ {(capped ? lines.slice(0, headLines) : lines).map((line, index) => ( +
{renderLine(line)}
+ ))} + {hidden > 0 && ( + + )} + {capped && lines.slice(lines.length - tailLines).map((line, index) => ( +
{renderLine(line)}
+ ))} +
+ ))} +
+ ) +} diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts new file mode 100644 index 0000000000..e4e2cc13e2 --- /dev/null +++ b/packages/client/ui-primitives/src/ansi.ts @@ -0,0 +1,153 @@ +// ANSI model behind TerminalBlock: anser splits the SGR runs, this module +// resolves each run's colors and decorations into a plain style record and +// folds the runs into per-line span arrays so a height cap can slice whole +// lines. Sequences anser does not turn into color (OSC, cursor movement, +// other C0 controls) are removed before parsing so they never reach the DOM +// as literal characters. + +import Anser from 'anser' +import type { CSSProperties } from 'react' + +/** + * The subset of one anser JSON chunk this module reads. anser's own types + * declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run + * that sets no color, so the null is spelled out here. + */ +interface AnsiChunk { + /** Run text with its SGR codes already removed. */ + content: string + /** Foreground as an `r, g, b` triple, or null when the run sets none. */ + fg: string | null + /** Background as an `r, g, b` triple, or null when the run sets none. */ + bg: string | null + /** SGR attributes in effect for the run, in the order they were declared. */ + decorations: readonly string[] +} + +/** One run of terminal text; `style` is undefined for text that carries no SGR state. */ +export interface AnsiSpan { + /** The run's plain text, free of escape sequences and newlines. */ + text: string + /** Resolved inline style, or undefined when the run needs no wrapper. */ + style: CSSProperties | undefined +} + +/** The spans of one output line, in order. */ +export type AnsiLine = readonly AnsiSpan[] + +/** + * The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple + * anser emits for them, mapped onto the theme tokens that carry the same + * semantic. Black and white both resolve to the primary label color so text + * stays legible under either theme instead of matching the surface it sits + * on; bright black takes the tertiary label color (the muted-gray role). + * Magenta and cyan have no token equivalent in this design system and fall + * through to anser's literal rgb, as do all 256-palette and truecolor values. + */ +const TOKEN_BY_BASIC_RGB: Record = { + '0,0,0': 'var(--dsw-alias-label-primary)', + '255,255,255': 'var(--dsw-alias-label-primary)', + '85,85,85': 'var(--dsw-alias-label-tertiary)', + '187,0,0': 'var(--dsw-alias-state-error-primary)', + '255,85,85': 'var(--dsw-alias-state-error-secondary)', + '0,187,0': 'var(--dsw-alias-state-success-primary)', + '0,255,0': 'var(--dsw-alias-state-success-secondary)', + '187,187,0': 'var(--dsw-alias-state-warn-primary)', + '255,255,85': 'var(--dsw-alias-state-warn-secondary)', + '0,0,187': 'var(--dsw-alias-state-business-primary)', + '85,85,255': 'var(--dsw-static-blue-400)', +} + +/** + * CSS for each SGR attribute anser reports. `blink` is deliberately absent — + * animated text is not reproduced. `reverse` never arrives here: anser + * consumes it by swapping the run's foreground and background. Underline and + * strikethrough share `textDecoration`, so in a run declaring both, the + * later declaration wins. + */ +const STYLE_BY_DECORATION: Record = { + bold: { fontWeight: 700 }, + dim: { opacity: 0.7 }, + italic: { fontStyle: 'italic' }, + underline: { textDecoration: 'underline' }, + strikethrough: { textDecoration: 'line-through' }, + hidden: { visibility: 'hidden' }, +} + +/** OSC strings (window title, hyperlinks), with or without their terminator. */ +const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g + +/** Escape sequences other than CSI: charset selection, single-shift, reset. */ +const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g + +/** C0 controls with no display meaning here; tab, newline and ESC survive for layout and anser's CSI split. */ +const INERT_CONTROL = /[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f]/g + +/** + * Apply carriage-return redraws: within a line, only the text after the last + * `\r` survives, which is what a terminal shows for progress output. A `\r` + * that only terminates a CRLF line is dropped first so those lines keep + * their text. SGR codes preceding a dropped redraw are dropped with it. + * @param text - output text, already free of OSC and non-CSI escapes. + * @returns the text with each line reduced to its final redraw. + */ +function applyCarriageReturns(text: string): string { + return text.split('\n').map((raw) => { + const line = raw.replace(/\r+$/, '') + return line.slice(line.lastIndexOf('\r') + 1) + }).join('\n') +} + +/** + * Remove every escape sequence and control character that carries no color, + * leaving CSI sequences for anser and `\n`/`\t` for layout. + * @param text - raw command output. + * @returns text whose only remaining escapes are CSI sequences. + */ +function sanitize(text: string): string { + const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '') + return applyCarriageReturns(escaped).replace(INERT_CONTROL, '') +} + +/** + * Resolve one run's colors and decorations. + * @param chunk - the anser chunk to style. + * @returns the run's inline style, or undefined when it carries no SGR state. + */ +function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined { + const style: CSSProperties = {} + const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})` + if (background !== undefined) style.backgroundColor = background + if (chunk.fg !== null) { + const literal = `rgb(${chunk.fg})` + // A run that paints its own background keeps anser's literal pair so the + // authored foreground/background contrast survives; a foreground-only run + // maps onto a theme token, which adapts to light and dark surfaces. + style.color = background === undefined + ? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal + : literal + } + for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration]) + return Object.keys(style).length === 0 ? undefined : style +} + +/** + * Parse command output into styled spans grouped by line. + * @param text - raw output text, which may contain ANSI escape sequences. + * @returns one entry per output line (always at least one, possibly empty). + */ +export function parseAnsiLines(text: string): AnsiLine[] { + let current: AnsiSpan[] = [] + const lines: AnsiSpan[][] = [current] + for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) { + const style = resolveStyle(chunk) + for (const [index, part] of chunk.content.split('\n').entries()) { + if (index > 0) { + current = [] + lines.push(current) + } + if (part !== '') current.push({ text: part, style }) + } + } + return lines +} diff --git a/packages/client/ui-primitives/src/clipboard.ts b/packages/client/ui-primitives/src/clipboard.ts new file mode 100644 index 0000000000..c01e802f31 --- /dev/null +++ b/packages/client/ui-primitives/src/clipboard.ts @@ -0,0 +1,48 @@ +// Package-internal clipboard write, shared by every copy control in this +// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the +// public surface: consumers get the components, not the host detection. + +/** + * Write text to the host clipboard, preferring the async Clipboard API and + * falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts) + * that omit it. + * @param text - the exact text to place on the clipboard. + * @returns true only when the host accepted the write. + */ +export async function writeClipboard(text: string): Promise { + // lib.dom types clipboard non-optional, but insecure contexts omit it — + // that runtime gap is exactly what this guard detects. + /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Denied permissions / iframe policy — do not claim success. + return false + } + } + // jsdom and older hosts: best-effort execCommand path when present. + // execCommand('copy') is the only clipboard fallback where the async API + // is missing; deprecated but deliberately retained. + /* eslint-disable @typescript-eslint/no-deprecated */ + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return false + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + return exec('copy') + } catch { + return false + } finally { + el.remove() + } + /* eslint-enable @typescript-eslint/no-deprecated */ +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 330a3145bf..dca6374fb8 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -17,6 +17,8 @@ export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' +export type { TerminalBlockProps } from './TerminalBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index de6a478af4..9c7e968053 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import clsx from 'clsx' +import { writeClipboard } from '../clipboard.ts' import { highlightToHtml } from './highlight.ts' import css from './CodeBlock.module.css' @@ -18,45 +19,6 @@ export interface CodeBlockProps { className?: string | undefined } -/** @returns true only when the host accepted the write. */ -async function writeClipboard(text: string): Promise { - // lib.dom types clipboard non-optional, but insecure contexts omit it — - // that runtime gap is exactly what this guard detects. - /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - // Denied permissions / iframe policy — do not claim success. - return false - } - } - // jsdom and older hosts: best-effort execCommand path when present. - // execCommand('copy') is the only clipboard fallback where the async API - // is missing; deprecated but deliberately retained. - /* eslint-disable @typescript-eslint/no-deprecated */ - const exec = typeof document.execCommand === 'function' - ? document.execCommand.bind(document) - : undefined - if (exec === undefined) return false - const el = document.createElement('textarea') - el.value = text - el.setAttribute('readonly', '') - el.style.position = 'fixed' - el.style.left = '-9999px' - document.body.appendChild(el) - el.select() - try { - return exec('copy') - } catch { - return false - } finally { - el.remove() - } - /* eslint-enable @typescript-eslint/no-deprecated */ -} - export function CodeBlock({ code, lang, className }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang]) diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.spec.ts new file mode 100644 index 0000000000..57a29d4f33 --- /dev/null +++ b/packages/client/ui-primitives/tests/ansi.spec.ts @@ -0,0 +1,188 @@ +// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs +// resolved into inline styles and folded into per-line span arrays, with every +// escape and control character that carries no color removed first. The DOM +// side of the same model (which runs get a span wrapper) is in +// terminal-block.spec.tsx. + +import { describe, expect, it } from 'vitest' +import { parseAnsiLines } from '../src/ansi.ts' + +const ESC = '\u001b' + +/** Paint `text` with the SGR `codes`, then reset. */ +function sgr(codes: string, text: string): string { + return `${ESC}[${codes}m${text}${ESC}[0m` +} + +/** The single span of a single-line, single-run parse. */ +function onlySpan(text: string) { + const lines = parseAnsiLines(text) + expect(lines).toHaveLength(1) + expect(lines[0]).toHaveLength(1) + return lines[0]![0]! +} + +describe('parseAnsiLines: text without SGR state', () => { + it('leaves plain text as one unstyled span', () => { + expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]]) + }) + + it('returns exactly one empty line for empty input', () => { + expect(parseAnsiLines('')).toEqual([[]]) + }) + + it('splits a multi-line run and drops the empty line between two blocks', () => { + expect(parseAnsiLines('a\n\nb')).toEqual([ + [{ text: 'a', style: undefined }], + [], + [{ text: 'b', style: undefined }], + ]) + }) + + it('keeps tabs, which the terminal surface needs for column layout', () => { + expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined }) + }) +}) + +describe('parseAnsiLines: basic colors mapped onto theme tokens', () => { + it.each<[string, string, string]>([ + ['30', 'black', 'var(--dsw-alias-label-primary)'], + ['37', 'white', 'var(--dsw-alias-label-primary)'], + ['90', 'bright black', 'var(--dsw-alias-label-tertiary)'], + ['31', 'red', 'var(--dsw-alias-state-error-primary)'], + ['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'], + ['32', 'green', 'var(--dsw-alias-state-success-primary)'], + ['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'], + ['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'], + ['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'], + ['34', 'blue', 'var(--dsw-alias-state-business-primary)'], + ['94', 'bright blue', 'var(--dsw-static-blue-400)'], + ])('SGR %s (%s) resolves to %s', (code, _name, token) => { + expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } }) + }) +}) + +describe('parseAnsiLines: colors with no token equivalent', () => { + it.each<[string, string, string]>([ + ['35', 'magenta', 'rgb(187, 0, 187)'], + ['36', 'cyan', 'rgb(0, 187, 187)'], + ['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'], + ['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'], + ])('SGR %s (%s) falls through to %s', (code, _name, literal) => { + expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal }) + }) +}) + +describe('parseAnsiLines: backgrounds', () => { + it('sets backgroundColor for a background-only run', () => { + expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' }) + }) + + it('keeps the literal foreground when the run paints its own background', () => { + expect(onlySpan(sgr('41;37', 'x')).style).toEqual({ + backgroundColor: 'rgb(187, 0, 0)', + color: 'rgb(255,255,255)', + }) + }) + + it('renders reverse video as the swapped pair anser reports', () => { + expect(onlySpan(sgr('31;7', 'x')).style).toEqual({ + backgroundColor: 'rgb(187, 0, 0)', + color: 'rgb(0, 0, 0)', + }) + }) +}) + +describe('parseAnsiLines: decorations', () => { + it.each<[string, string, Record]>([ + ['1', 'bold', { fontWeight: 700 }], + ['2', 'dim', { opacity: 0.7 }], + ['3', 'italic', { fontStyle: 'italic' }], + ['4', 'underline', { textDecoration: 'underline' }], + ['9', 'strikethrough', { textDecoration: 'line-through' }], + ['8', 'hidden', { visibility: 'hidden' }], + ])('SGR %s (%s) resolves to %o', (code, _name, style) => { + expect(onlySpan(sgr(code, 'x')).style).toEqual(style) + }) + + it('lets the later textDecoration win when a run declares underline and strikethrough', () => { + expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' }) + expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' }) + }) + + it('combines a color with several decorations in one style', () => { + expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({ + color: 'var(--dsw-alias-state-error-primary)', + fontWeight: 700, + fontStyle: 'italic', + }) + }) + + it('reproduces no animation for blink, leaving the run unstyled', () => { + expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined }) + }) +}) + +describe('parseAnsiLines: sequences that carry no color', () => { + it('removes an OSC string with its BEL terminator', () => { + expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined }) + }) + + it('removes an OSC string terminated by ST', () => { + expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined }) + }) + + it('removes non-CSI escapes such as charset selection and reset', () => { + expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined }) + }) + + it('removes inert C0 controls', () => { + expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined }) + }) + + it('keeps CSI sequences that only move the cursor out of the text', () => { + expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined }) + }) +}) + +describe('parseAnsiLines: carriage returns', () => { + it('keeps only the last redraw of a line', () => { + expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined }) + }) + + it('drops the SGR codes that preceded a discarded redraw', () => { + expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined }) + }) + + it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => { + expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([ + [{ text: 'a', style: undefined }], + [{ text: 'b', style: undefined }], + [], + ]) + }) + + it('applies the redraw per line, not across the whole text', () => { + expect(parseAnsiLines('one\rtwo\nthree')).toEqual([ + [{ text: 'two', style: undefined }], + [{ text: 'three', style: undefined }], + ]) + }) +}) + +describe('parseAnsiLines: runs spanning lines', () => { + it('carries one run\'s style onto every line it covers', () => { + expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([ + [{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + [{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + ]) + }) + + it('keeps several runs of one line in order', () => { + expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[ + { text: 'plain', style: undefined }, + { text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } }, + { text: 'tail', style: undefined }, + ]]) + }) +}) diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx new file mode 100644 index 0000000000..87e153a741 --- /dev/null +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled +// arms, the exit-status pill, the head/tail height cap and its expand control, +// and the copy control writing the raw output on both the accepted and the +// refused clipboard paths. writeClipboard's own return contract is pinned here +// too, since it is the seam both copy controls in this package share; the +// resolution of ANSI runs into styles is pinned in ansi.spec.ts, so only its +// DOM consequence (which runs get a span wrapper) is asserted here. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts' +import { writeClipboard } from '../src/clipboard.ts' + +const ESC = '\u001b' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered output rows, one string per visible line (CSS-module class prefix). */ +function outputLines(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered output lines, without the terminating newline. */ +function body(count: number): string { + return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n') +} + +describe('TerminalBlock prompt label', () => { + it('collapses the home directory itself to ~', () => { + render() + expect(screen.getByText('~')).toBeTruthy() + }) + + it('shows only the last segment below home', () => { + render() + expect(screen.getByText('Documents')).toBeTruthy() + }) + + it('ignores trailing separators on both the cwd and home', () => { + const view = render() + expect(view.getByText('~')).toBeTruthy() + view.rerender() + expect(view.getByText('~')).toBeTruthy() + }) + + it('drops trailing separators before taking the last segment', () => { + render() + expect(screen.getByText('Documents')).toBeTruthy() + }) + + it('takes the last segment when no home is known', () => { + render() + expect(screen.getByText('Projects')).toBeTruthy() + }) + + it('collapses a backslash home path to ~', () => { + render() + expect(screen.getByText('~')).toBeTruthy() + }) + + it('falls back to the raw path when it has no segment', () => { + render() + expect(screen.getByText('/')).toBeTruthy() + }) + + it('renders a plain $ with no cwd', () => { + render() + expect(screen.getByText('$')).toBeTruthy() + }) + + it('renders the command verbatim after the label', () => { + render() + expect(screen.getByText('git log --oneline | head -3')).toBeTruthy() + }) +}) + +describe('TerminalBlock states', () => { + it('running shows the command line only: no output, no placeholder, no copy', () => { + const view = render() + expect(view.getByText('sleep 5')).toBeTruthy() + expect(view.queryByText('partial')).toBeNull() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + }) + + it('running still shows a settled-looking status pill when one is supplied', () => { + render() + expect(screen.getByText('信号 SIGINT')).toBeTruthy() + }) + + it('settled with whitespace-only output shows the dimmed placeholder', () => { + const view = render() + expect(view.getByText('无输出')).toBeTruthy() + expect(view.queryByRole('button', { name: '复制' })).toBeNull() + }) + + it('settled with absent output shows the placeholder', () => { + render() + expect(screen.getByText('无输出')).toBeTruthy() + }) + + it('settled with an empty string shows the placeholder', () => { + render() + expect(screen.getByText('无输出')).toBeTruthy() + }) + + it('merges className onto the wrapper', () => { + const view = render() + expect(view.container.firstElementChild?.classList.contains('x')).toBe(true) + expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false) + }) + + it('drops the output text terminator instead of drawing a blank line', () => { + 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', '']) + }) + + it('renders ANSI runs as styled spans and plain text bare', () => { + const view = render() + const span = view.container.querySelector('span[style]') + expect(span?.textContent).toBe('bad') + expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary') + expect(outputLines(view.container)).toEqual(['bad ok']) + }) + + it('renders uncolored output with no span wrappers at all', () => { + const view = render() + expect(view.container.querySelectorAll('[class^="_line_"] span')).toHaveLength(0) + }) +}) + +describe('TerminalBlock status pill', () => { + it('renders no pill for a clean exit', () => { + const view = render() + expect(view.queryByText(/退出码|信号/u)).toBeNull() + }) + + it('renders no pill while the exit status is unknown', () => { + const view = render() + expect(view.queryByText(/退出码|信号/u)).toBeNull() + }) + + it('renders the exit-code pill for a non-zero exit', () => { + render() + expect(screen.getByText('退出码 1')).toBeTruthy() + }) + + it('renders the signal pill, which outranks the exit code', () => { + render() + expect(screen.getByText('信号 SIGKILL')).toBeTruthy() + expect(screen.queryByText(/退出码/u)).toBeNull() + }) +}) + +describe('TerminalBlock height cap', () => { + it('renders every line and no expand control under the cap', () => { + const view = render() + expect(outputLines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-expanded]')).toBeNull() + }) + + it('does not count the output terminator against the cap', () => { + const view = render() + expect(outputLines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-expanded]')).toBeNull() + }) + + it('slices head and tail over the cap and expands on click', () => { + const view = render() + // maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden. + expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10']) + const toggle = view.getByRole('button', { name: '展开其余 6 行输出' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toBe('… 其余 6 行') + + fireEvent.click(toggle) + expect(outputLines(view.container)).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起输出' }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(collapse.textContent).toBe('收起') + + fireEvent.click(collapse) + expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10']) + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + expect(outputLines(view.container)).toEqual(['line 1']) + expect(view.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy() + }) + + it('caps at the documented default when maxLines is absent', () => { + const view = render() + expect(outputLines(view.container)).toHaveLength(DEFAULT_TERMINAL_MAX_LINES) + expect(view.getByRole('button', { name: '展开其余 1 行输出' })).toBeTruthy() + }) +}) + +describe('TerminalBlock copy', () => { + it('copies the raw output, never the prompt line or the pill', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const output = `${ESC}[31mbad${ESC}[39m\n` + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + // Escape codes, the newline terminator, and nothing of the chrome around them. + expect(writeText).toHaveBeenCalledWith(output) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // While the ok label is showing, further clicks are no-ops. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('copies the whole output while the height cap hides its middle', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const output = `${body(10)}\n` + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith(output) + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when the host refuses the write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) +}) + +describe('writeClipboard', () => { + it('reports true after the async Clipboard API accepts the exact text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + await expect(writeClipboard('payload')).resolves.toBe(true) + expect(writeText).toHaveBeenCalledWith('payload') + }) + + it('reports false when the Clipboard API rejects', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) + + it('selects a detached textarea for the execCommand fallback and removes it after', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + let selected: string | undefined + const exec = vi.fn(() => { + selected = document.querySelector('textarea[readonly]')?.value + return true + }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: exec }) + await expect(writeClipboard('payload')).resolves.toBe(true) + expect(exec).toHaveBeenCalledWith('copy') + expect(selected).toBe('payload') + expect(document.querySelector('textarea')).toBeNull() + }) + + it('reports execCommand\'s own refusal verbatim', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: vi.fn(() => false) }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) + + it('reports false and still removes the textarea when execCommand throws', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: () => { + throw new Error('denied') + }, + }) + await expect(writeClipboard('payload')).resolves.toBe(false) + expect(document.querySelector('textarea')).toBeNull() + }) + + it('reports false on a host with neither clipboard path', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) + + it('reports false when navigator.clipboard exists without writeText', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {} }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9538051cd..53791fe2fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1075,6 +1075,9 @@ importers: '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 + anser: + specifier: ^2.3.5 + version: 2.3.5 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -7856,6 +7859,9 @@ packages: resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==} engines: {node: '>= 14.0.0'} + anser@2.3.5: + resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -12840,6 +12846,8 @@ snapshots: '@algolia/requester-fetch': 5.55.2 '@algolia/requester-node-http': 5.55.2 + anser@2.3.5: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {}