Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into trajectory-history-source

imccyu 1 месяц назад
Родитель
Сommit
b23c3d596a
100 измененных файлов с 3626 добавлено и 355 удалено
  1. 2 2
      .agents/notes/README.i18n.yaml
  2. 7 7
      .agents/notes/README.zh.md
  3. 6 0
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
  4. 14 0
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
  5. 14 0
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
  6. 3 3
      .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
  7. 1 1
      .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
  8. 1 1
      .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
  9. 87 1
      apps/web/tests/navigation-panes.e2e.ts
  10. 3 0
      apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
  11. 306 0
      apps/web/tests/terminal-card.snapshot.ts
  12. 9 5
      docs/i18n/terminology.md
  13. 2 2
      docs/postmortem/README.i18n.yaml
  14. 3 3
      docs/postmortem/README.zh.md
  15. 1 1
      examples/README.i18n.yaml
  16. 7 7
      examples/README.zh.md
  17. 2 2
      examples/acp-agent/README.i18n.yaml
  18. 6 6
      examples/acp-agent/README.zh.md
  19. 1 1
      examples/cordis-agent/README.i18n.yaml
  20. 4 4
      examples/cordis-agent/README.zh.md
  21. 2 2
      examples/headless-agent/README.i18n.yaml
  22. 5 5
      examples/headless-agent/README.zh.md
  23. 2 2
      examples/jsonrpc-agent/README.i18n.yaml
  24. 4 4
      examples/jsonrpc-agent/README.zh.md
  25. 1 1
      examples/tui-agent/README.i18n.yaml
  26. 15 15
      examples/tui-agent/README.zh.md
  27. 2 2
      native/README.i18n.yaml
  28. 3 3
      native/README.zh.md
  29. 2 2
      native/landlock-run/README.i18n.yaml
  30. 4 4
      native/landlock-run/README.zh.md
  31. 2 2
      native/landlock-run/packages/entry/README.i18n.yaml
  32. 3 3
      native/landlock-run/packages/entry/README.zh.md
  33. 2 2
      native/landlock-run/packages/linux-arm64/README.i18n.yaml
  34. 2 2
      native/landlock-run/packages/linux-arm64/README.zh.md
  35. 2 2
      native/landlock-run/packages/linux-x64/README.i18n.yaml
  36. 2 2
      native/landlock-run/packages/linux-x64/README.zh.md
  37. 2 2
      packages/acp/README.i18n.yaml
  38. 2 2
      packages/acp/README.zh.md
  39. 2 2
      packages/acp/acp/README.i18n.yaml
  40. 21 21
      packages/acp/acp/README.zh.md
  41. 2 2
      packages/bash/README.i18n.yaml
  42. 2 2
      packages/bash/README.zh.md
  43. 2 2
      packages/bash/bash-local/README.i18n.yaml
  44. 7 7
      packages/bash/bash-local/README.zh.md
  45. 2 2
      packages/bash/bash-sandbox/README.i18n.yaml
  46. 14 14
      packages/bash/bash-sandbox/README.zh.md
  47. 2 2
      packages/bash/bash/README.i18n.yaml
  48. 7 7
      packages/bash/bash/README.zh.md
  49. 81 3
      packages/client/connection/src/client/fixture.ts
  50. 2 2
      packages/client/hmr/README.i18n.yaml
  51. 6 6
      packages/client/hmr/README.zh.md
  52. 2 2
      packages/client/locale/README.i18n.yaml
  53. 2 2
      packages/client/locale/README.zh.md
  54. 2 2
      packages/client/modules/README.i18n.yaml
  55. 5 5
      packages/client/modules/README.zh.md
  56. 1 1
      packages/client/runtime/README.i18n.yaml
  57. 11 11
      packages/client/runtime/README.zh.md
  58. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  59. 3 1
      packages/client/ui-conversation/README.md
  60. 3 1
      packages/client/ui-conversation/README.zh.md
  61. 6 1
      packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
  62. 18 4
      packages/client/ui-conversation/src/client/chat/ToolRow.module.css
  63. 36 9
      packages/client/ui-conversation/src/client/chat/ToolRow.tsx
  64. 189 0
      packages/client/ui-conversation/src/client/contract/terminal-card-model.ts
  65. 4 2
      packages/client/ui-conversation/src/client/contract/tool-call-model.ts
  66. 14 0
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
  67. 73 27
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
  68. 15 1
      packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css
  69. 40 14
      packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
  70. 21 0
      packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
  71. 600 0
      packages/client/ui-conversation/tests/terminal-card.spec.tsx
  72. 1 1
      packages/client/ui-model/README.i18n.yaml
  73. 9 9
      packages/client/ui-model/README.zh.md
  74. 2 2
      packages/client/ui-models/README.i18n.yaml
  75. 1 1
      packages/client/ui-models/README.zh.md
  76. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  77. 3 1
      packages/client/ui-primitives/README.md
  78. 3 1
      packages/client/ui-primitives/README.zh.md
  79. 1 0
      packages/client/ui-primitives/package.json
  80. 3 1
      packages/client/ui-primitives/src/Pill.tsx
  81. 2 2
      packages/client/ui-primitives/src/StateDot.tsx
  82. 152 0
      packages/client/ui-primitives/src/TerminalBlock.module.css
  83. 237 0
      packages/client/ui-primitives/src/TerminalBlock.tsx
  84. 447 0
      packages/client/ui-primitives/src/ansi.ts
  85. 48 0
      packages/client/ui-primitives/src/clipboard.ts
  86. 2 0
      packages/client/ui-primitives/src/index.ts
  87. 1 39
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  88. 513 0
      packages/client/ui-primitives/tests/ansi.spec.ts
  89. 430 0
      packages/client/ui-primitives/tests/terminal-block.spec.tsx
  90. 2 2
      packages/client/ui-settings/README.i18n.yaml
  91. 1 1
      packages/client/ui-settings/README.zh.md
  92. 2 2
      packages/client/ui-sidebar/README.i18n.yaml
  93. 6 6
      packages/client/ui-sidebar/README.zh.md
  94. 2 2
      packages/client/ui-skill/README.i18n.yaml
  95. 7 7
      packages/client/ui-skill/README.zh.md
  96. 1 1
      packages/client/ui-slash/README.i18n.yaml
  97. 6 6
      packages/client/ui-slash/README.zh.md
  98. 2 2
      packages/client/ui-slots/README.i18n.yaml
  99. 5 5
      packages/client/ui-slots/README.zh.md
  100. 2 2
      packages/client/ui-subagent/README.i18n.yaml

+ 2 - 2
.agents/notes/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
+#   pnpm run verify-translation-pairing --write .agents/notes/README.md
 README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0
-README.zh.md: ddecac79519219c4a76cf9ba19edea312eea9d0d
+README.zh.md: a3369a94c6761e27567b1408d98a81665443f2ef

+ 7 - 7
.agents/notes/README.zh.md

@@ -11,7 +11,7 @@
 - **生命周期**(顶层文件夹)是 Agent Note 的状态,Agent Note 随状态变化在文件夹之间移动:
   - **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。
   - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。
-  - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的三个配对文件。
+  - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的英文、中文和伴随记录三文件
 - **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。
 
 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。
@@ -28,7 +28,7 @@
 |---|---|
 | `feature` | 面向用户或模型的新功能。 |
 | `bug-fix` | 修正缺陷或弥补事故复盘(postmortem)发现的缺口。 |
-| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 |
+| `simplification` | 在不增加功能的前提下移除代码、行为或对外范围。 |
 | `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 |
 | `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 |
 | `testing` | 测试基础设施与策略。 |
@@ -39,13 +39,13 @@
 
 当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。
 
-归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随文件,并修复或删除入站链接。归档时只允许对内容做这些更改。
+归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。
 
 封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。
 
 ## 何时需要写一份
 
-每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
+每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
 
 更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
 
@@ -112,7 +112,7 @@ Status: <status>
 
 ### 曾考虑的替代方案——必需
 
-每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的
+每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——这正是 Agent Note 旨在防止的问题
 
 替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释:
 
@@ -122,8 +122,8 @@ Status: <status>
 
 ### 在生命周期之间移动
 
-将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。
+将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——也就是将 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写变成可机械检查的规则。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。
 
 ### 中文对侧文件
 
-`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。
+`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文对侧文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。

+ 6 - 0
.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: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
+2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf

Разница между файлами не показана из-за своего большого размера
+ 14 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md


Разница между файлами не показана из-за своего большого размера
+ 14 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md


+ 3 - 3
.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

+ 1 - 1
.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
 

+ 1 - 1
.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)渲染
 
 ## 曾考虑的替代方案
 

+ 87 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -23,6 +23,7 @@ import { saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'navigation-panes-web-e2e'
 
@@ -181,6 +182,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
     await bashRow.click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
+    // The card's own controls are outside the summary row and must not open
+    // details either — the terminal card is read in place.
+    await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
     // Read summaries are host-open file links; they also must not open details.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await fileLink.waitFor({ timeout: 10_000 })
@@ -188,12 +193,93 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).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<HTMLElement>('[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 })
+    // The run-state dot's color is the whole point of it and is the one thing
+    // jsdom cannot report: --dsw-* tokens resolve only against the real theme
+    // stylesheet. This command settled cleanly, so the dot must be the green
+    // success token — a red one here would read as a failed command.
+    const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
+      // The token lives on body, so the probe must sit in the same cascade.
+      const probe = document.createElement('span')
+      probe.style.color = 'var(--dsw-alias-state-success-primary)'
+      document.body.appendChild(probe)
+      const success = getComputedStyle(probe).color
+      probe.remove()
+      return {
+        state: node.getAttribute('data-state'),
+        color: getComputedStyle(node as HTMLElement).color,
+        success,
+        // One label per card (the state is the call's), so it hangs off the
+        // prompt column rather than the row the dot sits in.
+        label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
+        // The dot precedes the prompt label in document order, which is what
+        // puts it to the left of the `$`.
+        beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
+          === Node.DOCUMENT_POSITION_FOLLOWING,
+        // The dot lives in the card's OWN left padding, so it sits inside the
+        // card box yet left of the prompt text. Owning the reservation as padding
+        // rather than margin is what keeps a consumer's own margin from
+        // cancelling it and letting a container clip the dot — geometry jsdom
+        // cannot compute.
+        insideCard: (node as HTMLElement).getBoundingClientRect().left
+          >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
+        leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
+          <= (node.closest('[class*="_promptLine_"]')
+            ?.querySelector('[class*="_cwd_"]')
+            ?.getBoundingClientRect().left ?? -Infinity),
+      }
+    })
+    expect(dot.state).toBe('done')
+    expect(dot.label).toBe('已完成')
+    expect(dot.beforePrompt).toBe(true)
+    expect(dot.insideCard).toBe(true)
+    expect(dot.leftOfPrompt).toBe(true)
+    // Resolved through the theme token, not a literal hex in the component.
+    expect(dot.success).toMatch(/^rgb/)
+    expect(dot.color).toBe(dot.success)
+    // 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(slotErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'seed.jsonl', 'trajectory.expected.md',
+      'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
     ])
   })
 })

+ 3 - 0
apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md

@@ -0,0 +1,3 @@
+- text: 已完成 {{workspace}} echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK

+ 306 - 0
apps/web/tests/terminal-card.snapshot.ts

@@ -0,0 +1,306 @@
+// @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 65's `bash` on the keyed BashRow registration (resident body). Turn 65
+// carries what turn 60's two clean prompt rows cannot — SGR runs resolved to
+// --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit
+// pill; turn 60 carries the multi-line command's per-line prompt rows.
+//
+// The details panel's Output section is NOT covered here: tool rows stopped
+// being details-panel click targets, and nothing else in the assembled
+// application opens that panel, so the surface cannot be driven end to end.
+// Its terminal rendering stays pinned in ui-conversation's
+// tests/terminal-card.spec.tsx, which mounts DetailsPanel with a selection
+// directly.
+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 {
+    // One entry per command line: a multi-line command is one row per line.
+    prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
+      `${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`),
+    // Dots per prompt row: exactly one, on the first row — the exit status the
+    // view carries is the whole call's, so a dot per line would assert a
+    // per-line outcome bash does not report.
+    dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
+      row.querySelectorAll('[data-state]').length),
+    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'),
+    },
+    // The run-state dot at the head of the prompt line, by its StateDot state.
+    runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null,
+    runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
+    // Every color the ANSI parser emits resolves through a --dsw-* token, so
+    // the card follows the theme instead of painting literal terminal rgb.
+    // Scoped to the output lines: the run-state dot is an inline-styled span
+    // too, and its geometry is not an ANSI-resolved color.
+    colors: [...new Set([...card.querySelectorAll('[class*="_line_"] 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<void> {
+  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<HTMLElement>('[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 65 (the one carrying the ANSI sample). */
+function keyedBashRow(): Element {
+  // Anchored on the BashRow wrapper (summary row + resident card), not on the
+  // summary row itself: the summary now shows the presenter's description (the
+  // contract's above-card text), so the command lives only in the card below it.
+  const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
+    .map(node => node.parentElement)
+    .find((node): node is HTMLElement => node !== null && visibleText(node).includes('pnpm run check'))
+  if (row === undefined) throw new Error('keyed bash row for turn 65 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 comes
+  // from the sample's authored exit status (its body deliberately carries no
+  // `[exit code: N]` marker, since the real presenter consumes that one), 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": "复制",
+      "dotsPerPromptRow": [
+        1,
+      ],
+      "expander": {
+        "expanded": "false",
+        "label": "展开其余 13 行输出",
+        "text": "… 其余 13 行",
+      },
+      "lines": [
+        "Running 4 checks",
+        "✓ typecheck                                          1.82s",
+        "✓ lint                                               0.94s",
+        "✓ duplication                                        2.10s",
+        "StateDot.tsx                100%     100%        100%         -",
+        "markdown/Markdown.tsx       100%     100%        100%         -",
+        "",
+        "1 of 4 checks failed",
+      ],
+      "prompt": [
+        "nested pnpm run check",
+      ],
+      "runState": "error",
+      "runStateLabel": "失败",
+      "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": "复制",
+      "dotsPerPromptRow": [
+        1,
+        0,
+      ],
+      "expander": null,
+      "lines": [
+        "total 2",
+        "drwxr-xr-x fixture",
+        "-rw-r--r-- demo.txt",
+      ],
+      "prompt": [
+        "fixture ls -la",
+        "$ echo done",
+      ],
+      "runState": "done",
+      "runStateLabel": "已完成",
+      "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": 21,
+      "expanderLabel": "收起输出",
+    }
+  `)
+})

+ 9 - 5
docs/i18n/terminology.md

@@ -54,7 +54,7 @@
 | Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |
 | schema | schema | | | |
 | schema DSL | schema DSL | | | |
-| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |
+| seam | seam | | 接缝 | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |
 | skill | skill | skill(技能) | | |
 | spawn | spawn | | | |
 | steering | steering | steering(中途引导) | | |
@@ -74,21 +74,24 @@
 | adapter | 适配器 | | | |
 | adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |
 | append-only | 仅追加 | | | |
-| artifact | 产物 | | | |
+| artifact | 产物 | | 制品 | |
 | backend | 后端 | | | |
 | background task | 后台任务 | | | |
 | block | 块 | | | |
 | build target | 构建目标 | | | |
 | cancel | 取消 | | | |
+| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |
+| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 |
 | feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |
 | feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |
 | checkpoint | 检查点 | | | |
 | chunk | 分片 | | | |
 | compaction | 压缩 | 压缩(compaction) | | |
 | companion tool | 配套工具 | | | |
+| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |
 | Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |
 | config key | 配置键 | | | Cordis 插件配置中的单个字段 |
-| consumer | 消费方 | | | |
+| consumer | 消费方 | | 消费者 | |
 | content block | 内容块 | | | |
 | Cookbook | 实操手册 | | | 文档标题用语 |
 | context | 上下文 | | | |
@@ -145,9 +148,10 @@
 | persistence | 持久化 | | | |
 | pipeline | 流水线 | | | |
 | plugin | 插件 | | | |
+| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |
 | prompt | 提示词 | | | |
 | provider | 提供方 | | | |
-| provider-neutral | 提供方无关 | | | |
+| provider-neutral | 提供方无关 | | 提供方中立 | |
 | quality gate | 质量门禁 | | | |
 | quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |
 | reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |
@@ -165,7 +169,7 @@
 | sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |
 | smoke test | 冒烟测试 | | | |
 | snapshot | 快照 | | | |
-| source of truth | 真源 | | | |
+| source of truth | 真源 | | 事实来源、唯一来源 | |
 | spine | 主干 | | | |
 | staged | 暂存 | | | 沿用 git 官方中文翻译 |
 | stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |

+ 2 - 2
docs/postmortem/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
+#   pnpm run verify-translation-pairing --write docs/postmortem/README.md
 README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1
-README.zh.md: 2ce6de475c705b02cd9dabfb2181929d81478e2c
+README.zh.md: e364ef30e342f8484a40f35e3970dea0f2f86ef3

+ 3 - 3
docs/postmortem/README.zh.md

@@ -2,13 +2,13 @@
 
 [English](README.md) | 中文
 
-事故复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。
+事故复盘记录的是:一个 bug 流入了不该流入的环节(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。
 
-事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露
+事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及为此新增了哪些具体防护措施,以确保同类 bug 下次出现时会明确报错
 
 当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。
 
-每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、持久的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。
+每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、可长期沿用的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。
 
 | # | 标题 |
 |---|---|

+ 1 - 1
examples/README.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write examples/README.md
 README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f
-README.zh.md: 72ab92602d0a53cabdbfa8bc34838061df25d1c7
+README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1

+ 7 - 7
examples/README.zh.md

@@ -2,11 +2,11 @@
 
 [English](README.md) | 中文
 
-展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。
+展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。
 
 ## headless-agent
 
-非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。
+非交互式 agent(智能体)演示:接受一个位置参数形式的任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。
 
 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。
 
@@ -18,18 +18,18 @@
 
 ## jsonrpc-agent
 
-通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。
+通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。
 
 ## cordis-agent
 
-**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。
+**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。
 
-使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
+使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
 
 ## acp-agent
 
-作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。
+一个通过 JSON-RPC stdio 公开、作为 **Agent Client Protocol (ACP)** 自动化服务器运行的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。
 
 运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。
 
-默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。
+默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;请求更广泛沙箱权限的重试会通过 ACP 触发一次性的机器权限请求。

+ 2 - 2
examples/acp-agent/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
+#   pnpm run verify-translation-pairing --write examples/acp-agent/README.md
 README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961
-README.zh.md: 0c5f8866ea640843513fd9a4c15a17ed4db59d3b
+README.zh.md: 84482aad8352ab38527dcf4d9e1bfefc8d496c91

+ 6 - 6
examples/acp-agent/README.zh.md

@@ -2,29 +2,29 @@
 
 [English](README.md) | 中文
 
-通过 JSON-RPC stdio 提供的自动化导向 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。它面向父 agent(智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。
+通过 JSON-RPC stdio 提供的面向自动化的 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 服务器。它面向 parent agent(父智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。
 
 ```sh
 pnpm run demo:acp             # needs DEEPSEEK_API_KEY (repo-root .env or env)
 pnpm run demo:code-mode acp   # same protocol with the Code Mode tool transport
 ```
 
-该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture 服务器。
+该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture(测试前置数据)服务器。
 
 ## 协议通道
 
-Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;叶节点的附加项必须使用 stderr 输出诊断信息。
+Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;该叶节点新增的组件必须使用 stderr 输出诊断信息。
 
 自动化契约(支持的方法、基线提示词内容、已提交文本输出,以及有意缺少的 UI 界面)位于 [`@deepseek-ai/dsh-acp`](../../packages/acp/acp/README.md)。
 
 ## 会话 workspace 与权限
 
-每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 与文件系统变更会根据该会话 cwd 解析 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。
+每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 和文件系统修改会以该会话 cwd 为基准应用 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。
 
-在 `workspace-write` 下,模型请求扩大沙箱权限的重试会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;解除对话框或答案不可用时会失败闭合。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。
+在 `workspace-write` 下,如果模型重试请求更广泛的沙箱访问权限,就会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;客户端放弃选择或无法给出答复时,系统会按拒绝处理。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。
 
 ## 快照测试
 
-此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖场景包括抛出/挂起行为,可选 `workspace/` fixture(测试前置数据)则为外部状态检查预置环境
+此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖配置涵盖抛错/挂起行为,可选的 `workspace/` fixture 则为环境状态检查预置状态
 
 大多数场景锁定后端行为,而非 ACP 专用行为;[仅面向自动化的 ACP 决策](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)说明了为何该覆盖仍与传输层耦合。

+ 1 - 1
examples/cordis-agent/README.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write examples/cordis-agent/README.md
 README.md: 55970e932bc16d8361932daa9ea55af83ef73d33
-README.zh.md: c2873b6de96a8b47ad8ea4fb2cf03a7501406300
+README.zh.md: a8ec332d8b3673d6656663eb3bfd7d37a4e328f6

+ 4 - 4
examples/cordis-agent/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时 Plugin,并再次卸载它们。临时 Plugin 可跨 turn 保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
+自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时插件,并卸载它们。临时插件可跨轮次保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他会话。`ctx.fs` 和 `ctx.web` 仅以能力提供方形式加载,供这些插件使用。设计详见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
 
 ## 运行
 
@@ -15,7 +15,7 @@ pnpm run demo:cordis web  # browser UI at http://127.0.0.1:3081
 pnpm run demo:cordis acp  # ACP server
 ```
 
-预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身:
+预期演示分阶段进行:先验证监听器链路,再让 agent(智能体)扩展自身:
 
 ```
 > Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash.
@@ -30,8 +30,8 @@ pnpm run demo:cordis acp  # ACP server
   [tool call] cordis_unmount({"id": "dyn-1"})
 ```
 
-请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可挂载两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。
+请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写插件代码所用的生成服务/事件资料。还可挂载两个协作临时插件(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。
 
 ## 端到端测试
 
-`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖
+`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析,以及收到 EOF 后正常退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态监听器,测试验证其带标记的控制台输出行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时插件。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 包含相关单元测试,并受逐文件 100% 覆盖率门禁约束

+ 2 - 2
examples/headless-agent/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
+#   pnpm run verify-translation-pairing --write examples/headless-agent/README.md
 README.md: 445804a2611e5e8093eadf345ad10a2a7984c012
-README.zh.md: 68ec718afe0b2aca276be2689cbae74167ee1c7b
+README.zh.md: 956bc82e77f79c3f05e4b51297fd5365e6e89be1

+ 5 - 5
examples/headless-agent/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。
+无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。
 
 ## 运行
 
@@ -15,12 +15,12 @@ pnpm run demo:headless --output-format json -- "summarize the implementation"
 pnpm run demo:headless --output-format stream-json -- "run the focused tests"
 ```
 
-必须提供且只能提供一个非空位置任务;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父工具事件和结果对外显示。
+必须提供一个且仅一个非空的任务位置参数;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父会话的工具事件和结果对外显示。
 
-每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷新、释放并退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动 workspace、运行命令、spawn 子 agent,并消耗提供方 token。
+每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷写持久化数据、执行 dispose(资源释放),再退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动时所在的工作区、运行命令、spawn 子 agent,并消耗提供方 token。
 
 ## 高级与快照接线
 
-[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下的测试拥有无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。
+[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下涵盖无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。
 
-包级 [CLI 契约](../../packages/examples/cli-demo/README.md)记录输出记录、退出状态、取消、持久化以及模型/token 影响。
+这份包(package)级 [CLI(命令行界面)契约](../../packages/examples/cli-demo/README.md) 说明输出记录、退出状态、取消、持久化以及模型/token 影响。

+ 2 - 2
examples/jsonrpc-agent/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
+#   pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md
 README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8
-README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3
+README.zh.md: 43290fc3659750724679a370be5b33ffdca2a5cb

+ 4 - 4
examples/jsonrpc-agent/README.zh.md

@@ -2,16 +2,16 @@
 
 [English](README.md) | 中文
 
-面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、console logger、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。
+面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、控制台日志记录器、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。
 
 面向模型的工具为:
 
 - `bash`,仅前台
 - `read`、`write` 和 `edit`
-- `subagent`,使用一个前台进程内 spawn 提供方
+- `subagent`,使用一个在进程内以前台方式运行的 spawn 提供方
 - `todo_write`
 
-周边运行时还加载 JSONL 会话持久化和自动上下文压缩(compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。
+周边运行时还加载 JSONL 会话持久化和自动上下文压缩(context compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。
 
 ## 运行时环境
 
@@ -24,4 +24,4 @@
 | `DSH_SESSION_ROOT` | JSONL 轨迹目录 |
 | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 |
 
-通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。
+通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。

+ 1 - 1
examples/tui-agent/README.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write examples/tui-agent/README.md
 README.md: ea8695d37ea247a38644392a4572c1ea9855fd44
-README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53
+README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a

+ 15 - 15
examples/tui-agent/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合单次管道的任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。
+全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合管道调用单次任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。
 
 ## 运行
 
@@ -13,13 +13,13 @@
 pnpm run demo:tui
 ```
 
-演示脚本和可安装的 `dsh` CLI([`apps/cli`](../../apps/cli/README.md))都会作为已交付的默认配置启动此示例的 `cordis.yml`;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为 workspace
+演示脚本和可安装的 `dsh` CLI(命令行界面,见 [`apps/cli`](../../apps/cli/README.md))都会以此示例的 `cordis.yml` 作为已交付的默认配置启动;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为工作区
 
-输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次操作都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。
+输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次 bash 调用都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。文件系统工具和 bash 都会相对于会话工作区解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。
 
 `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。
 
-TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan <message>` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model <model>` 和 `/model <provider>/<model>` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。
+TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan <message>` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model <model>` 和 `/model <provider>/<model>` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。
 
 ### 恢复早先的会话
 
@@ -29,11 +29,11 @@ TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡
 dsh --resume <prior-session-id>
 ```
 
-`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久 goal 阶段和实时/已持久化状态。已安装的 `dsh` 宿主会刷新并释放当前应用,然后以 `dsh --resume <id>` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume <id>` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。
+`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久化目标阶段和实时/已持久化状态。已安装的 `dsh` 宿主会等待刷写完成,对当前应用执行 dispose(资源释放),然后以 `dsh --resume <id>` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume <id>` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。
 
 ## Code Mode
 
-[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。
+[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。
 
 ```sh
 pnpm run demo:code-mode        # this overlay under the TUI (default UI)
@@ -54,27 +54,27 @@ pnpm run demo:code-mode acp    # the acp-agent example's same-shaped overlay
 |---|---|
 | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 |
 | `llm-deepseek` | 默认原生适配器 |
-| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 |
+| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 中可替换的实现侧。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 |
 | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent |
 | `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent |
 | `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 |
 | `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 |
 | `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 |
-| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径根据会话 workspace 解析 |
+| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径相对于会话工作区解析 |
 
 ## 端到端测试(`pnpm run test:e2e`)
 
 与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader):
 
 - `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。
-- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的声称
-- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后释放整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。
-- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,表层缩减(替换节点遮蔽旧节点),且 agent 在压缩后仍给出正确最终答案。
-- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。
-- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言线上工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。
+- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的说法
+- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后 dispose 整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。
+- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,模型可见内容缩减(一个替换节点遮蔽了较旧节点),且 agent 在压缩后仍给出正确最终答案。
+- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。
+- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言协议层工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。
 
-这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM 对话,Code Mode 覆盖欢迎行,以及恢复失败退出路径。
+这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM(大语言模型)对话,Code Mode 覆盖配置的欢迎行,以及恢复失败退出路径。
 
 ## 快照测试
 
-`tests/snapshots/<scenario>/session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。使用 `pnpm run test:snapshot:refresh` 刷新仅展示变更;已录制模型旅程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 拥有场景矩阵,以及已录制旅程、瞬时包快照与 PTY 覆盖之间的分工。
+`tests/snapshots/<scenario>/session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。对于仅涉及展示的变更,使用 `pnpm run test:snapshot:refresh`;已录制的模型流程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 规定了场景矩阵,以及已录制旅程、包级瞬态快照与 PTY 覆盖之间的分工。

+ 2 - 2
native/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
+#   pnpm run verify-translation-pairing --write native/README.md
 README.md: 84808b2ee9dafa4f9f980c35a81ebe12480a4f5d
-README.zh.md: f73d4176454d9a577bf674a6bfe3f15cce3402b4
+README.zh.md: 276db0e655f2d632da9729c787b613cd231b2d40

+ 3 - 3
native/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`node-addon-landlock-run` 的记录真源:这是 harness 从 npm 消费的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包系列的发布镜像。
+`node-addon-landlock-run` 的权威源码位于此处:这是 harness 从 npm 引入并使用的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包(package)系列的发布镜像。
 
 ## 发布镜像
 
@@ -17,6 +17,6 @@
 1. 先通过常规 harness PR 将启动器更改落地于此;触发 `Landlock Run` 工作流,并确保其所有任务通过。
 2. 在镜像 checkout 中替换 `.github/` 以外的所有内容:`git -C <mirror> rm -rq -- . ':!.github'`,然后执行 `git -C <harness> archive HEAD:native/landlock-run | tar -x -C <mirror>`,最后执行 `git -C <mirror> add -A` 并提交。
 3. 在镜像中按照其发布清单(`docs/release.md`)操作:`pnpm release:commit <version>` → 合并 → 标记 `vX.Y.Z` → 两阶段 `Release` 工作流(先以 `publish=false` 预演,再从标签以 `publish=true` 发布)。
-4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中提升 harness 消费方的依赖范围。
+4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中上调 harness 消费方的依赖版本范围。
 
-镜像不得分叉:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。
+发布镜像不得与此处的权威源码产生分歧:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。

+ 2 - 2
native/landlock-run/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
+#   pnpm run verify-translation-pairing --write native/landlock-run/README.md
 README.md: 284d5df764cf5a5205973696211aee2366d3b76e
-README.zh.md: 7163314abac0362afccee6fcc4506a84701cc27a
+README.zh.md: f369799cc8dcfb6de7c4b7b8c18857693d310418

+ 4 - 4
native/landlock-run/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以每平台预构建 npm 包加一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要在文件系统允许清单下运行不可信命令、但不能限制自身的 agent harness 和其他宿主。
+一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以按平台预构建的 npm 包(package)以及一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要让不可信命令在文件系统允许清单约束下运行、同时保持自身不受限制的 agent harness(智能体框架)和其他宿主。
 
 第一个工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。
 
@@ -34,12 +34,12 @@ if (probe(launcher) !== 'unusable') {
 }
 ```
 
-公开 API 有意保持简
+公开 API 有意保持简:
 
 - `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。
 - `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。
 - `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。
-- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT` (125):契约常量。
+- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125):契约常量。
 
 完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。
 
@@ -57,4 +57,4 @@ pnpm build:native    # this Linux architecture's binaries (apt-get install musl-
 pnpm test
 ```
 
-二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 的每架构 runner 则是记录中的构建者。发布流程详见 [docs/release.md](docs/release.md)。
+二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 各架构 runner 产出的构建则作为正式发布依据。发布流程详见 [docs/release.md](docs/release.md)。

+ 2 - 2
native/landlock-run/packages/entry/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
+#   pnpm run verify-translation-pairing --write native/landlock-run/packages/entry/README.md
 README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3
-README.zh.md: 03dd18969d8e5c94be31605201b74b126ae5c06d
+README.zh.md: 6f8136c33560515af891b8873d007eb3e9b013e0

+ 3 - 3
native/landlock-run/packages/entry/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包解析每平台预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。
+用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包(package)定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。
 
 ```js
 import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
@@ -13,6 +13,6 @@ if (probe(launcher) !== 'unusable') {
 }
 ```
 
-启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:始终失败闭合,绝不失败开放。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。
+启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。
 
-平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回确定且不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。
+平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。

+ 2 - 2
native/landlock-run/packages/linux-arm64/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
+#   pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-arm64/README.md
 README.md: e5117988cf0bae2227edaa041700c2f75753899c
-README.zh.md: 93fee68207a9f03a54f214c69904d44729ed71e5
+README.zh.md: abbd0d1040638ad4d64f3ab219bedcd845eb5a9b

+ 2 - 2
native/landlock-run/packages/linux-arm64/README.zh.md

@@ -2,8 +2,8 @@
 
 [English](README.md) | 中文
 
-面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。
+面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。
 
-该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。
+该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。
 
 同级包:`node-addon-landlock-run-linux-x64`。

+ 2 - 2
native/landlock-run/packages/linux-x64/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
+#   pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-x64/README.md
 README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0
-README.zh.md: b1fa2e3f16c20c4d7e287c17ab0ba946e6cadbea
+README.zh.md: e813bcef7143b46a756e5716234f3bc3850de712

+ 2 - 2
native/landlock-run/packages/linux-x64/README.zh.md

@@ -2,8 +2,8 @@
 
 [English](README.md) | 中文
 
-面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。
+面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。
 
-该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。
+该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。
 
 同级包:`node-addon-landlock-run-linux-arm64`。

+ 2 - 2
packages/acp/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
+#   pnpm run verify-translation-pairing --write packages/acp/README.md
 README.md: 326615210e5cfc39004fc5ab7462623089ac4126
-README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c
+README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa

+ 2 - 2
packages/acp/README.zh.md

@@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开
 
 | 包 | 职责 |
 |---|---|
-| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 |
+| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 |
 
-与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约
+与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器

+ 2 - 2
packages/acp/acp/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
+#   pnpm run verify-translation-pairing --write packages/acp/acp/README.md
 README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
-README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84
+README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5

+ 21 - 21
packages/acp/acp/README.zh.md

@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
+通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
 
-此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。
+此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
 
 ## 插件
 
@@ -15,7 +15,7 @@
 | `provider` | 无 | 每个已创建 agent 的初始提供方路由。 |
 | `model` | 无 | 每个已创建 agent 的初始模型。 |
 
-两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
+两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
 
 ## 协议契约
 
@@ -23,19 +23,19 @@
 |---|---|
 | `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
 | `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
-| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
-| `session/prompt` | 接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 |
-| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
+| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
+| `session/prompt` | 接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 |
+| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
 | `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
-| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
+| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
 
-一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer
+一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器
 
-已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
+已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
 
 ## 生命周期
 
-客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。
+客户端断开连接与 Cordis 的 dispose(资源释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。
 
 ## 运行
 
@@ -45,13 +45,13 @@
 
 ### 提示词文本
 
-#### 模型所见内容
+#### 模型看到的内容
 
-`session/prompt` 文本块会原样接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
+`session/prompt` 文本块会原样接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
 
 #### Token 影响
 
-提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。
+提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。
 
 #### KV Cache 影响
 
@@ -59,21 +59,21 @@
 
 ### 权限决策
 
-#### 模型所见内容
+#### 模型看到的内容
 
-没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果
+不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用
 
 #### Token 影响
 
-只有拥有决策的工具结果会贡献 token。
+只有该工具结果会贡献 token。
 
 #### KV Cache 影响
 
-通过所属工具结果仅追加。
+随该工具的结果仅追加。
 
-## 已知限制与延后工作
+## 已知限制与暂缓事项
 
 - **仅新会话**:不支持加载、列出、恢复、删除和 fork。
-- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。
-- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线
-- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭
+- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。
+- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输
+- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能

+ 2 - 2
packages/bash/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
+#   pnpm run verify-translation-pairing --write packages/bash/README.md
 README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
-README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db
+README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea

+ 2 - 2
packages/bash/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。
+规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。
 
 | 包 | 职责 | ctx key |
 |---|---|---|
@@ -11,4 +11,4 @@
 | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) |
 | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
 
-接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
+接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。

+ 2 - 2
packages/bash/bash-local/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
+#   pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
 README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
-README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516
+README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17

+ 7 - 7
packages/bash/bash-local/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有
+`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责
 
 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。
 
@@ -24,11 +24,11 @@
 
 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
 
-- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。
+- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。
 - **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
-- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
+- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
 - **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
-- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
+- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
 
 ## 模型体验
 
@@ -36,12 +36,12 @@
 
 #### KV Cache 影响
 
-不会直接失效;请求前缀变更由具名消费方负责。
+不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
 
 ## 已知限制与暂缓事项
 
-- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
-- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
+- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
+- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
 - **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
 - **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
 

+ 2 - 2
packages/bash/bash-sandbox/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
+#   pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
 README.md: ca77a9c626784b29145712535d69de4afbd3a697
-README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3
+README.zh.md: 4ecc8d533f7af373bdacd133d44a8def6d265868

+ 14 - 14
packages/bash/bash-sandbox/README.zh.md

@@ -2,11 +2,11 @@
 
 [English](README.md) | 中文
 
-消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
+这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
 
 包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
 
-每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。
+每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
 
 | 模式 | 文件影响 |
 |---|---|
@@ -17,12 +17,12 @@
 语义:
 
 - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
-- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
-- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。
+- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
+- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。
 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
 
-seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖
+该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值
 
 ```yaml
 - id: sandbox
@@ -36,7 +36,7 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
   name: '@deepseek-ai/dsh-bash-sandbox'
 ```
 
-无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
+无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
 
 ## 模型体验
 
@@ -44,11 +44,11 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
 
 #### 模型看到的内容
 
-基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
+基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
 
 #### Token 影响
 
-在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。
+在 `bash` 可见的请求上,schema 固定增加少量内容;模式切换不增加上下文 token。
 
 #### KV Cache 影响
 
@@ -62,29 +62,29 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
 
 #### Token 影响
 
-除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。
+除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到上下文压缩(context compaction)
 
 #### KV Cache 影响
 
-仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
+仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
 
 ### 间接的 Bash 工具错误
 
 #### 模型看到的内容
 
-如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
+如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
 
 #### Token 影响
 
-该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。
+该次调用会在相应条件下显示错误文本,该文本会保留在历史记录中直到上下文压缩。
 
 #### KV Cache 影响
 
-仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
+仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
 
 ## 已知限制与暂缓事项
 
 - **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
-- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
+- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
 - **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
 - **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。

+ 2 - 2
packages/bash/bash/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
+#   pnpm run verify-translation-pairing --write packages/bash/bash/README.md
 README.md: d7bf746969f52000fe298b65b995b7c631d8001c
-README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339
+README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c

+ 7 - 7
packages/bash/bash/README.zh.md

@@ -4,7 +4,7 @@
 
 **bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
 
-本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
+本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
 
 | 包 | 职责 |
 |---|---|
@@ -13,19 +13,19 @@
 | `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
 | `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
 
-该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
+该拆分与 LLM(大语言模型) seam(`LlmService`/`LlmAdapter`)及 agent(智能体)工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
 
 ## 服务 API(`ctx.bash`)
 
 | 成员 | 语义 |
 |---|---|
-| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 |
+| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止导致的终止都会 resolve 为描述性 `BashRunResult`。 |
 | `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 |
 | `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 |
 | `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
 | `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
 
-实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。
+实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。
 
 ## 词汇
 
@@ -33,7 +33,7 @@
 
 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
 
-`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
+`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
 
 ## 模型体验
 
@@ -41,9 +41,9 @@
 
 #### KV Cache 影响
 
-不会直接失效;请求前缀变更由具名消费方负责。
+不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
 
 ## 已知限制与暂缓事项
 
 - **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。
-- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。
+- **前台超时始终由执行器负责**:seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。

+ 81 - 3
packages/client/connection/src/client/fixture.ts

@@ -80,6 +80,62 @@ 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 65, authored to carry every feature
+ * the terminal card draws that turn 60's two prompt rows 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. The exit status is authored separately in
+ * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
+ * presenter CONSUMES its `[exit code: N]` marker out of the body, because a
+ * terminal card shows the exit as its own pill and leaving the marker in would
+ * render it twice (packages/bash/tool-bash/src/render.ts).
+ */
+const TERMINAL_OUTPUT_FIXTURE = [
+  sgr(1, 'Running 4 checks'),
+  `${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'),
+].join('\n')
+
+/**
+ * Exit status for each terminal sample, keyed by its output text. Authored
+ * alongside the sample rather than parsed back out of its trailing marker,
+ * which is the bash tool's own job and not something to reimplement here.
+ */
+const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
+  [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
+}
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
     push({ type: 'step/end', data: { turn, step: 0 } })
     push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   }
-  toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
+  // A two-line command, so the fixture covers the terminal card's one-row-per-
+  // command-line prompt (and that the card still marks the call exactly once).
+  toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
   toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
   toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
   toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
     { content: '实现 fixture 样本', status: 'in_progress' },
     { content: '浏览器验收', status: 'pending' },
   ]
+  // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
+  // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
+  // whose prompt label is its last segment, and a non-zero exit authored beside
+  // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
+  // `[exit code: N]` marker, since the real presenter consumes that one out of
+  // the body. Named `bash`, so it also covers
+  // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
+  // row) — the two chat-row shapes the terminal card renders in.
+  //
+  // Ordered BEFORE the todo turn deliberately: the standing plan retires at the
+  // next `turn/start`, so a turn appended after it would leave the dock's plan
+  // strip empty and take the todo surfaces' own coverage with it.
+  toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
+
   const todoArgs = JSON.stringify({ todos: fixtureTodos })
-  toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+  toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
   // The real tool appends the snapshot mid-execution — between tool/call and
   // tool/result — so the fixture reproduces that exact ordering (the last
   // toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -250,7 +322,10 @@ 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.
     case 'fx-bash':
+    case 'bash':
       return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
     case 'fx-write':
       return {
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
   if (call === undefined) return undefined
   switch (call.card) {
     case 'terminal':
-      return { card: 'terminal', output: resultText, exitCode: 0 }
+      // The sample's own exit status, authored beside it: re-parsing the
+      // trailing marker here would duplicate the bash tool's `parseExitStatus`,
+      // which this client-side fixture cannot import.
+      return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
     case 'diff':
       return { card: 'diff', diffs: call.diffs }
     case 'generic':

+ 2 - 2
packages/client/hmr/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
+#   pnpm run verify-translation-pairing --write packages/client/hmr/README.md
 README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
-README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
+README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4

+ 6 - 6
packages/client/hmr/README.zh.md

@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
+为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
 
-浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、以 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR,无需 builder→host 通道。
+浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
 
 ## 模型体验
 
@@ -12,10 +12,10 @@
 
 #### KV Cache 影响
 
-无;该包既不组装也不发送提供方请求。
+无;该包(package)既不组装也不发送提供方请求。
 
 ## 已知限制与暂缓事项
 
-- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
-- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
-- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地
+- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
+- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
+- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现

+ 2 - 2
packages/client/locale/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
+#   pnpm run verify-translation-pairing --write packages/client/locale/README.md
 README.md: 9015af2b44a33771b06863ace139fe97695df616
-README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba
+README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf

+ 2 - 2
packages/client/locale/README.zh.md

@@ -10,9 +10,9 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以
 
 #### KV Cache 影响
 
-无;该包既不组装也不发送提供方请求。
+无;该包(package)既不组装也不发送提供方请求。
 
 ## 已知限制与暂缓事项
 
 - **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
-- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。
+- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载。

+ 2 - 2
packages/client/modules/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
+#   pnpm run verify-translation-pairing --write packages/client/modules/README.md
 README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
-README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0
+README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72

+ 5 - 5
packages/client/modules/README.zh.md

@@ -2,11 +2,11 @@
 
 [English](README.md) | 中文
 
-客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
+客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包(package)的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
 
-惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 请求另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法交付部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
+惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
 
-解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;图行(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达 hook(抓取 + 执行,只注册;并发调用共享一个进行中的 task);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取(HMR hook)
+解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子
 
 ## 模型体验
 
@@ -18,5 +18,5 @@
 
 ## 已知限制与暂缓事项
 
-- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表叶;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
-- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只逐记录清点自身拥有的样式标签 id。
+- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
+- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。

+ 1 - 1
packages/client/runtime/README.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/runtime/README.md
 README.md: d283cf19572f4888d17884472ea0d2272109de7f
-README.zh.md: e7006a5ccbbc128b8d272199851057732b76ada3
+README.zh.md: b2d479e1ba277738390de2122c295ce44c77b1e0

+ 11 - 11
packages/client/runtime/README.zh.md

@@ -2,19 +2,19 @@
 
 [English](README.md) | 中文
 
-客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
+客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
 
 ## Workspace 与 Session 列表
 
-Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
+Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
 
-`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
+`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
 
-SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
+SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
 
 ## New Session 与 blank 镜像
 
-`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
+`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
 
 ## Code Mode 子调用索引
 
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 
 ## Session 标题投影
 
-`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
+`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。
 
 ## 会话模型选择
 
@@ -30,14 +30,14 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 
 ## 模型体验
 
-无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
+无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
 
 #### KV Cache 影响
 
-更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
+更改目标可能改变提供方侧的缓存复用,或使其失效;该包(package)本身不会改变提示词前缀。
 
 ## 已知限制与暂缓事项
 
-- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
-- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
-- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)
+- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
+- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
+- **插件组合包从该包导入时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题

+ 2 - 2
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: ddf2988799acf45357c3cc23a5517e59de59a60e
-README.zh.md: d3147745424afe76a5ae1ca09e5f1f0534741b06
+README.md: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d
+README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910

+ 3 - 1
packages/client/ui-conversation/README.md

@@ -12,6 +12,8 @@ Approvals take over the composer through the chain this package declares: `Appro
 
 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 a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. 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). 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. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card 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`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', 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 (`<done>/<total> 已完成 · <active item>` 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 plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> 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 standing 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.
@@ -33,7 +35,7 @@ None; this package neither assembles nor sends a provider request.
 ## Known Limitations and Deferred Work
 
 - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
-- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
+- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
 - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
 - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
 - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.

+ 3 - 1
packages/client/ui-conversation/README.zh.md

@@ -10,6 +10,8 @@
 
 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。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,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `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`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 
 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
@@ -33,7 +35,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 ## 已知限制与暂缓事项
 
 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
-- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
+- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。

+ 6 - 1
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx

@@ -10,6 +10,7 @@ import {
   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'
 
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
 
 export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
   const model = toolRowModel(toolName, block, cwd)
+  const terminal = terminalCardModel(block, cwd)
   const singleFile = model.filePath !== undefined
   return (
     <ToolRow
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
       toolName={toolName}
       icon={VARIANT_ICONS[model.variant]}
       title={model.title}
-      summary={model.summary}
+      // A terminal presenter's description is the contract's above-card text, so
+      // it outranks the args-derived summary here exactly as it does in BashRow.
+      summary={terminal?.description ?? model.summary}
       // Single-file tools never expose an args body — the path link is the only action.
       body={singleFile ? null : model.body}
+      terminal={terminal}
       state={model.state}
       filePath={model.filePath}
       onOpenFile={singleFile ? openFile : undefined}

+ 18 - 4
packages/client/ui-conversation/src/client/chat/ToolRow.module.css

@@ -175,9 +175,23 @@ 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;
 }
+
+/* Indented to the body's own column so the description reads as the card's
+   heading rather than as another summary row, and sits tight against the card
+   below it. Its own rule: grouping it with a body would put description
+   typography on a `CodeBlock` wrapper and change that body's spacing. */
+.terminalDescription {
+  margin: 4px 0 0 22px;
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+}

+ 36 - 9
packages/client/ui-conversation/src/client/chat/ToolRow.tsx

@@ -1,14 +1,18 @@
 // ToolRow: the single-line tool summary row (figma component set 122:9479) —
 // 16px leading slot (state dot / tool icon, chevron on hover or 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
+// 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. Expand state is
 // component-local view state. File-tool summaries are path links that open
 // through the host; the row itself is not a details-panel control.
 
 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'
 
@@ -20,8 +24,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
@@ -52,17 +63,25 @@ export function ToolRow({
   title,
   summary,
   body,
+  terminal,
   state,
   expandOnRowClick = false,
   filePath,
   onOpenFile,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
+  const terminalBody = terminal ?? null
   // A row that names a single file keeps one interaction (open that path);
-  // args expand is off whether or not the open callback is wired yet.
+  // args expand is off whether or not the open callback is wired yet. Terminal
+  // material still expands: only the file variants carry a path, so a terminal
+  // card and a file link never land on the same row.
   const singleFile = filePath !== undefined
   const fileLink = singleFile && onOpenFile !== undefined
-  const expandable = body !== null && !singleFile
+  const expandable = (body !== null && !singleFile) || 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 = () => {
@@ -137,9 +156,17 @@ export function ToolRow({
           </>
         )}
       </div>
-      {open && (variant === 'code'
-        ? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
-        : <div className={css.body}>{body}</div>)}
+      {/* The terminal presenter's description belongs ABOVE the card per the
+          render-intent contract, so an expanded terminal row keeps showing it
+          even though the collapsed summary is hidden while open. */}
+      {open && terminalBody?.description !== undefined && (
+        <div className={css.terminalDescription}>{terminalBody.description}</div>
+      )}
+      {open && (terminalBody !== null
+        ? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
+        : variant === 'code'
+          ? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
+          : <div className={css.body}>{text}</div>)}
     </div>
   )
 }

+ 189 - 0
packages/client/ui-conversation/src/client/contract/terminal-card-model.ts

@@ -0,0 +1,189 @@
+/**
+ * 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 { resolveToolPath, 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 interface TerminalCardModel {
+  /**
+   * The props {@link TerminalBlock} draws. Held as a nested object so a render
+   * site spreads exactly the primitive's own surface and can never leak a
+   * neighbouring field into it.
+   */
+  card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
+  /**
+   * The call view's model-authored description, which the contract defines as
+   * rendering ABOVE the card (the card itself has no description slot). Absent
+   * when the presenter supplied none, or when the window dropped the call side;
+   * a row then keeps its args-derived summary.
+   */
+  description: string | undefined
+}
+
+/**
+ * Resolve a terminal view's working directory the way the render-intent
+ * contract assigns to the UI bridge: an absolute path is used as-is, a relative
+ * one joins under the session workspace, and an omitted one IS the session
+ * workspace. A pure presenter cannot see the session cwd, which is why this
+ * resolution belongs here rather than in the tool. Without a session cwd there
+ * is nothing to resolve against, so a relative path stays as authored and an
+ * omitted one stays absent (the prompt row then draws a bare `$`).
+ * @param viewCwd - the cwd the terminal call view carries, if any.
+ * @param sessionCwd - the session workspace root, if the caller knows it.
+ * @returns the working directory for the prompt label, or undefined.
+ */
+function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
+  if (viewCwd === undefined || viewCwd === '') return sessionCwd
+  if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
+  return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
+}
+
+/**
+ * Collapse `.` and `..` segments so the prompt label names the directory the
+ * command actually ran in. The bash executor resolves the workdir before
+ * running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
+ * are preserved as authored (a Windows path keeps its backslashes) because this
+ * value is only ever displayed; a `..` that would climb past the root is
+ * dropped, which is what a filesystem does with it. A UNC path's `server` and
+ * `share` are part of its root, not poppable segments: Windows cannot climb
+ * above a share, so `\\\\server\\share` with a `..` stays there.
+ * @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
+ * @returns the same path with those segments resolved.
+ */
+function normalizeSegments(path: string): string {
+  if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
+  // A UNC path is `\\\\server\\share\\...`: the server and share form the root,
+  // so they are split off here and neither is a segment `..` may pop. Its
+  // separator is fixed to a backslash, since a joined relative part may have
+  // introduced a forward slash that UNC syntax does not use.
+  const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
+  if (unc !== null) {
+    // Both groups are mandatory in the pattern, so destructuring types them as
+    // strings without an assertion.
+    const [matched, server, share] = unc
+    const root = `\\\\${String(server)}\\${String(share)}`
+    // Rooted: what follows the share hangs off it, so a `..` at the top is
+    // dropped rather than kept — Windows cannot climb above a share.
+    const rest = collapse(path.slice(matched.length), true)
+    return rest === '' ? root : `${root}\\${rest}`
+  }
+  const backslashed = path.includes('\\') && !path.includes('/')
+  const separator = backslashed ? '\\' : '/'
+  const rooted = /^[/\\]/.test(path)
+  const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
+  const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
+  const leading = rooted ? separator : ''
+  return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
+}
+
+/**
+ * Collapse the `.`/`..` segments of a path body against a known root state.
+ * @param body - the path after any drive letter or UNC root.
+ * @param rooted - the body hangs off a root, so a `..` at its top is dropped
+ *   the way a filesystem drops one; without a root the `..` is kept, since it
+ *   stays meaningful against a cwd this function cannot see.
+ * @param separator - separator to rejoin with (default `/`).
+ * @returns the collapsed body, without leading or trailing separators.
+ */
+function collapse(body: string, rooted: boolean, separator = '/'): string {
+  const kept: string[] = []
+  for (const segment of body.split(/[/\\]/)) {
+    if (segment === '' || segment === '.') continue
+    if (segment === '..') {
+      if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
+      else if (!rooted) kept.push(segment)
+      continue
+    }
+    kept.push(segment)
+  }
+  return kept.join(separator)
+}
+
+/**
+ * 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.
+ * @param sessionCwd - the session workspace root, which resolves an omitted or
+ *   relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
+ * @returns the terminal-card props, or null for the generic path.
+ */
+export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): 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 : {
+      description: call.description,
+      card: {
+        command: call.title,
+        cwd: resolveTerminalCwd(call.cwd, sessionCwd),
+        output: undefined,
+        exitCode: undefined,
+        signal: undefined,
+        running: true,
+      },
+    }
+  }
+  const result = block.resultView?.card === 'terminal' ? block.resultView : null
+  if (result === null) return null
+  return {
+    description: call?.description,
+    card: {
+      // The result's title REPLACES the pending one when the tool supplies it
+      // (the presentation contract's replacement-title rule); the call title is
+      // what a result without one keeps.
+      command: result.title ?? call?.title ?? '',
+      // Only a PRESENT call view can mean "omitted the cwd, so use the
+      // workspace". When the window dropped the call head there is no cwd
+      // anywhere — the result view carries none — and the original call may
+      // well have used an explicit workdir, so the prompt draws a bare `$`
+      // rather than naming a directory this card cannot know.
+      cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
+      output: result.output,
+      exitCode: result.exitCode,
+      signal: result.signal,
+      running: false,
+    },
+  }
+}

+ 4 - 2
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

+ 14 - 0
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css

@@ -92,3 +92,17 @@
 .code[data-error] {
   color: var(--dsw-alias-state-error-primary);
 }
+
+/* Above the card, which is where the render-intent contract puts a terminal
+   call's description; the panel has no summary row to carry it. */
+.terminalDescription {
+  margin: 0 0 6px;
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+}
+
+/* The terminal card sits directly under its section label, so it drops the
+   primitive's standalone vertical margin; the section owns the spacing. */
+.terminal {
+  margin: 0;
+}

+ 73 - 27
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
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
   }
 }
 
-export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
+export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
   const selection = useStore(s => s.selection)
+  // Session workspace root: an omitted or relative terminal cwd resolves
+  // against it, which the pure presenter cannot see.
+  const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
   const callId = selection?.callId
   // materialFor builds a fresh wrapper; shallowEqual short-circuits on its
   // stable members (result node reference rides the snapshot's structural sharing).
@@ -95,15 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
                 )}
                 <section className={css.section}>
                   <div className={css.sectionLabel}>Output</div>
-                  {/* materialFor invariant: result===null ⇔ running (a settled
-                        material always carries its result node). */}
-                  {material.result === null
-                    ? <div className={css.empty}>运行中…</div>
-                    : (
-                      <pre className={css.code} data-error={material.result.isError || undefined}>
-                        {renderResult(material.result)}
-                      </pre>
-                    )}
+                  {/* Keyed by the selected call: the body owns per-call view
+                      state (the terminal card's expand and copy), which React
+                      would otherwise carry into the next selection because the
+                      panel does not unmount between calls. */}
+                  <OutputBody key={callId} material={material} cwd={sessionCwd} />
                 </section>
               </>
             )}
@@ -112,6 +123,41 @@ 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}.
+ * @param props.cwd - the session workspace root, resolving the terminal view's cwd.
+ * @returns the Output section's body element.
+ */
+function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
+  const terminal = terminalCardModel(material.block, cwd)
+  if (terminal !== null) {
+    // The contract renders the presenter's description above the card, and the
+    // panel has no summary row to carry it, so it is drawn here.
+    return (
+      <>
+        {terminal.description !== undefined && (
+          <div className={css.terminalDescription}>{terminal.description}</div>
+        )}
+        <TerminalBlock {...terminal.card} className={css.terminal} />
+      </>
+    )
+  }
+  // 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 <div className={css.empty}>运行中…</div>
+  const result = material.block
+  return (
+    <pre className={css.code} data-error={result.isError || undefined}>
+      {renderResult(result)}
+    </pre>
+  )
+}
+
 /** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
 function renderResult(node: ToolResultNode): string {
   const parts: string[] = []

+ 15 - 1
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 {
   position: relative; /* sweep-glare overlay anchor */

+ 40 - 14
packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx

@@ -3,10 +3,20 @@
 // 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 and is not a details-panel target either (tool rows stopped being
+// one), so its terminal body is resident rather than expand-gated as in
+// ToolRow, and the card's own copy and expand controls are the row's only
+// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
+// flow's tighter cap over the block's own default of 16 — and the block's
+// internal expander keeps a long output from taking over the message flow.
 
 import type { Context } from 'cordis'
-import { IconApiOutline14, StateDot } 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,24 +39,40 @@ 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 resident below it. The summary row is not a
+ * details-panel control (tool rows stopped being one), so the card's copy and
+ * expand controls are the row's only interactions.
+ */
 export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
   const model = toolRowModel(toolName, block)
+  // Session workspace root: the terminal view's cwd resolves against it (an
+  // omitted workdir IS the workspace), which the pure presenter cannot do.
+  const cwd = useSessions(list => list.byId[sessionId]?.cwd)
+  const terminal = terminalCardModel(block, cwd)
   const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
   const status = stateStatus(model.state)
   return (
-    <div
-      className={css.root}
-      data-sample={isChild ? 'bash-scoped' : 'bash-global'}
-      data-variant="bash"
-      data-state={model.state}
-    >
-      <span className={css.leading}>{leadingFor(model.state)}</span>
-      {status !== null && <span className={css.visuallyHidden}>{status}</span>}
-      {isChild && <span className={css.scopeBadge}>scoped</span>}
-      <span className={css.title}>{model.title}</span>
-      <span className={css.sep} aria-hidden />
-      <span className={css.summary}>{model.summary}</span>
+    <div className={css.card}>
+      <div
+        className={css.root}
+        data-sample={isChild ? 'bash-scoped' : 'bash-global'}
+        data-variant="bash"
+        data-state={model.state}
+      >
+        <span className={css.leading}>{leadingFor(model.state)}</span>
+        {status !== null && <span className={css.visuallyHidden}>{status}</span>}
+        {isChild && <span className={css.scopeBadge}>scoped</span>}
+        <span className={css.title}>{model.title}</span>
+        <span className={css.sep} aria-hidden />
+        {/* The terminal presenter's description is the contractual
+            above-card summary; it outranks the args-derived one. */}
+        <span className={css.summary}>{terminal?.description ?? model.summary}</span>
+      </div>
+      {terminal !== null && (
+        <TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
+      )}
     </div>
   )
 }

+ 21 - 0
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx

@@ -97,6 +97,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',
@@ -165,6 +170,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(<ToolRow {...rowProps} expandOnRowClick />)
+    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(<ToolRow {...rowProps} body={null} expandOnRowClick />)
+    expect(view.queryByRole('button')).toBeNull()
+  })
+
   it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
     const open = vi.fn()
     const view = render(

+ 600 - 0
packages/client/ui-conversation/tests/terminal-card.spec.tsx

@@ -0,0 +1,600 @@
+// @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 }
+
+/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
+function runStateOf(container: HTMLElement): string | null {
+  return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
+}
+
+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<Extract<ToolCallView, { card: 'terminal' }>>): 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<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
+  card: 'terminal', output: 'a.ts  b.ts\nc.ts  d.ts\n', exitCode: 0, ...over,
+})
+
+const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
+  callId: 'c1', name: 'bash', argsRaw: ARGS,
+  turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
+})
+
+const settled = (over?: Partial<ToolResultNode>): 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({
+      description: 'List files',
+      card: {
+        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({
+      description: 'List files',
+      card: {
+        command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
+        exitCode: 2, signal: undefined, running: false,
+      },
+    })
+    expect(terminalCardModel(settled({
+      resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
+    }))?.card.signal).toBe('SIGTERM')
+  })
+
+  it('takes the result view\'s replacement title over the pending one', () => {
+    // The presentation contract defines a result title as REPLACING the pending
+    // title, so a tool that rewrites it at settle time must win here.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ title: 'pnpm run check' }),
+      resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
+    }))?.card.command).toBe('pnpm run check --filter web')
+    // Without one, the call's title is what the card keeps.
+    expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
+  })
+
+  it('resolves the cwd against the session workspace the way the bridge must', () => {
+    // Omitted workdir — the common bash call — IS the session workspace.
+    expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
+    // A relative workdir joins under it.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: 'packages/ui' }),
+    }), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
+    // An absolute one is used as-is.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '/srv/other' }),
+    }), '/w/app')?.card.cwd).toBe('/srv/other')
+    // With no session cwd there is nothing to resolve against: a relative path
+    // stays as authored and an omitted one stays absent (a bare `$` prompt).
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: 'packages/ui' }),
+    }))?.card.cwd).toBe('packages/ui')
+    expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
+    // The running arm resolves identically.
+    expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
+  })
+
+  it('normalizes a relative workdir so the label names the directory actually used', () => {
+    // The bash executor resolves the workdir before running, so `..` against
+    // /w/app runs in /w — the card must say `w`, not `..`.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '/w/app')?.card.cwd).toBe('/w')
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '.' }),
+    }), '/w/app')?.card.cwd).toBe('/w/app')
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../sibling' }),
+    }), '/w/app')?.card.cwd).toBe('/w/sibling')
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: './nested/../other' }),
+    }), '/w/app')?.card.cwd).toBe('/w/app/other')
+    // A `..` that would climb past the root is dropped, as a filesystem does.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../../..' }),
+    }), '/w')?.card.cwd).toBe('/')
+    // An absolute path carrying segments normalizes too.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '/srv/./app/../other' }),
+    }), '/w/app')?.card.cwd).toBe('/srv/other')
+    // A Windows path keeps its separators.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
+    }), '/w')?.card.cwd).toBe('C:\\ws')
+    // Without a session cwd a relative `..` has nothing to resolve against, so
+    // it survives as authored rather than being silently dropped.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../elsewhere' }),
+    }))?.card.cwd).toBe('../elsewhere')
+  })
+
+  it('keeps a UNC server and share as an unpoppable root', () => {
+    // Windows cannot climb above a share, so `..` from the share root stays put.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
+    // Below the share it pops normally, keeping the UNC separators.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+    // Several `..` cannot escape the root either.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../../..' }),
+    }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+  })
+
+  it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
+    // A truncated call carries no cwd anywhere: the result view has none, and
+    // the original call may have used an explicit workdir. Falling back to the
+    // session workspace here would name a directory the card cannot know.
+    expect(terminalCardModel(settled({
+      call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
+    }), '/w/app')?.card.cwd).toBeUndefined()
+    // A present call view that omits its cwd still means the workspace.
+    expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
+  })
+
+  it('carries the call view\'s description, which the contract renders above the card', () => {
+    expect(terminalCardModel(settled())?.description).toBe('List files')
+    expect(terminalCardModel(running())?.description).toBe('List files')
+    // A presenter that supplies none, and a window-truncated call side, both
+    // leave it absent so the row keeps its args-derived summary.
+    expect(terminalCardModel(settled({
+      callView: { card: 'terminal', title: 'ls' },
+    }))?.description).toBeUndefined()
+    expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
+  })
+
+  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' }),
+    }))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
+    expect(terminalCardModel(settled(truncated))?.card).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, openFile: 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(<GenericToolCard {...ownerProps(settled())} />)
+    // 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(<GenericToolCard {...ownerProps(settled({
+      resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
+    }))} />)
+    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('renders a multi-line command as one prompt row per line', () => {
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: callTerminal({ title: 'ls -la\necho done' }),
+    }))} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
+    expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
+    // Still one dot for the call, on the first row.
+    expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
+  })
+
+  it('the fallback row shows the presenter description, not the args summary', () => {
+    // Any terminal-declaring tool without its own keyed row lands here, so the
+    // contract's above-card description has to win at this render site as well.
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: callTerminal({ description: 'Terminal 3' }),
+    }))} />)
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+  })
+
+  it('keeps the presenter description visible once the terminal card is expanded', () => {
+    // The contract puts the description ABOVE the card. The collapsed summary is
+    // hidden while a row is open, so an expanded terminal row has to draw it
+    // itself or the description would only ever be visible collapsed.
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: callTerminal({ description: 'Terminal 3' }),
+    }))} />)
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+  })
+
+  it('a running terminal call expands to the prompt line with no output yet', () => {
+    const view = render(<GenericToolCard {...ownerProps(running())} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(view.queryByText('复制')).toBeNull()
+    // The card states its own run state: a running command reads as running
+    // even though it has no output yet to distinguish it from an empty settle.
+    expect(runStateOf(view.container)).toBe('ongoing')
+  })
+
+  it('a non-terminal call keeps the args-JSON text body', () => {
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: null, resultView: null,
+    }))} />)
+    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(<GenericToolCard {...ownerProps(settled({
+      call: { name: 'bash', argsRaw: '' },
+    }))} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+  })
+})
+
+describe('BashRow terminal card', () => {
+  const list = () => createSnapshotStore<SessionListState>({
+    ids: [SID],
+    byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
+    current: undefined,
+    phase: 'ready',
+  })
+
+  const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
+    callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
+    sessionId: SID, useSessions: bindSnapshotSelector(list()),
+  } as unknown as ToolRowProps)
+
+  it('renders the command output under the summary row, without an expand gesture', () => {
+    const view = render(<BashRow {...rowProps(settled())} />)
+    expect(view.getByText('List files')).toBeTruthy()
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+    // The card's controls are the row's only interactions: a bash row is not a
+    // path link and no longer a details-panel target, so nothing here navigates.
+    expect(view.container.querySelector('[data-clickable]')).toBeNull()
+    expect(view.getByText('复制')).toBeTruthy()
+  })
+
+  // The row's leading StateDot and the card's run-state dot describe the same
+  // command, so a running row whose card claimed 'done' would be a contradiction
+  // the reader sees on one line.
+  it('agrees with the summary row about the run state', () => {
+    const runningView = render(<BashRow {...rowProps(running())} />)
+    expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
+    expect(runStateOf(runningView.container)).toBe('ongoing')
+    cleanup()
+    const settledView = render(<BashRow {...rowProps(settled())} />)
+    expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
+    expect(runStateOf(settledView.container)).toBe('done')
+  })
+
+  it('shows the terminal presenter\'s description instead of the args summary', () => {
+    // `terminal_send`-style presenters author a description the args do not
+    // repeat; the contract puts it above the card, which is this row's summary.
+    const view = render(<BashRow {...rowProps(settled({
+      callView: callTerminal({ description: 'Terminal 3' }),
+    }))} />)
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+  })
+
+  it('keeps the args-derived summary when the presenter authored no description', () => {
+    const view = render(<BashRow {...rowProps(settled({
+      callView: { card: 'terminal', title: 'ls -la' },
+    }))} />)
+    expect(view.getByText('List files')).toBeTruthy()
+  })
+
+  it('a non-terminal bash call (background start) renders the summary row alone', () => {
+    const view = render(<BashRow {...rowProps(settled({
+      callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
+      resultView: { card: 'generic' },
+    }))} />)
+    expect(view.getByText('List files')).toBeTruthy()
+    expect(view.queryByText(/a\.ts/)).toBeNull()
+  })
+})
+
+describe('DetailsPanel Output section', () => {
+  function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
+    localStorage.clear()
+    const chat = createChatStore().create()
+    if (selection !== null) chat.actions.select(selection)
+    const sessions = createSnapshotStore<SessionListState>(cwd === undefined
+      ? { ids: [], byId: {}, current: undefined, phase: 'ready' }
+      : {
+        ids: [SID],
+        byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
+        current: SID,
+        phase: 'ready',
+      })
+    const workspaces = createSnapshotStore<WorkspaceListState>({
+      items: [], state: 'idle', phase: 'ready', error: null,
+      baselinesReady: true, recentWorkspaceId: undefined,
+    })
+    return render(
+      <DetailsPanel
+        sessionId={SID}
+        useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
+        useSessions={bindSnapshotSelector(sessions)}
+        useWorkspaces={bindSnapshotSelector(workspaces)}
+        useInput={(() => { throw new Error('unused') })}
+        inputActions={{ setDraft: () => {}, submit: () => {} }}
+        useProjection={(() => undefined)}
+        useStore={bindSnapshotSelector(chat)}
+        actions={chat.actions}
+        closeDetails={vi.fn()}
+      />,
+    )
+  }
+
+  function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
+    return {
+      sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
+      pending: [], queue: [], 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' }
+
+  // The panel never unmounts between selections, so per-call view state has to
+  // be keyed off the selected call or it leaks into the next one.
+  it('resets the card\'s expand state when the selected call changes', () => {
+    const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
+    const view = mount(snapshot({
+      nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
+    }), target)
+    fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
+    expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
+    // A second call, selected without unmounting the panel, starts collapsed.
+    cleanup()
+    const second = mount(snapshot({
+      nodes: [settled({
+        callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
+      })],
+    }), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
+    expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
+  })
+
+  it('renders the presenter description above the card', () => {
+    const view = mount(snapshot({
+      nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
+    }), target)
+    const description = view.getByText('Terminal 3')
+    const card = view.container.querySelector('[data-terminal]')
+    expect(card).not.toBeNull()
+    // Above, not below: document order is what places it as the card's heading.
+    expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+  })
+
+  it('resolves the prompt cwd against the session workspace', () => {
+    const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
+    // No workdir in the call view: the prompt label is the workspace basename.
+    expect(view.getByText('app')).toBeTruthy()
+  })
+
+  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()
+    expect(runStateOf(view.container)).toBe('ongoing')
+  })
+
+  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')
+  })
+
+  // The panel resolves a sub-dispatch through the same material as a native
+  // call, so a sub-call that DID carry terminal views would render the card.
+  // The shipped wire cannot produce that yet: `session.ts` folds
+  // `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
+  // the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
+  // pins the resolution path with views injected directly, and the arm below
+  // pins what the shipped path actually shows today.
+  it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
+    const view = mount(snapshot({
+      codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
+    }), target)
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+  })
+
+  it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
+    const view = mount(snapshot({
+      codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
+    }), target)
+    // No terminal card: the generic path renders the result text in the Output
+    // section's <pre> (the Input section has its own, hence the scoping).
+    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    const output = view.getByText('Output').closest('section')
+    expect(output?.querySelector('pre')?.textContent).toContain('a.ts  b.ts')
+  })
+
+  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(
+      <DetailsPanel
+        sessionId={SID}
+        useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
+        useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
+          { ids: [], byId: {}, current: undefined, phase: 'ready' }))}
+        useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
+          items: [], state: 'idle', phase: 'ready', error: null,
+          baselinesReady: true, recentWorkspaceId: undefined,
+        }))}
+        useInput={(() => { throw new Error('unused') })}
+        inputActions={{ setDraft: () => {}, submit: () => {} }}
+        useProjection={(() => undefined)}
+        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
+    // <pre> 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()
+  })
+})

+ 1 - 1
packages/client/ui-model/README.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
 README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
-README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85
+README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4

+ 9 - 9
packages/client/ui-model/README.zh.md

@@ -2,20 +2,20 @@
 
 [English](README.md) | 中文
 
-模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
+模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。
 
 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。
 
-## Model Experience
+## 模型体验
 
-间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
+间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
 
-#### KV Cache effect
+#### KV Cache 影响
 
-切换路由可能降低或作废提供方侧后续请求的缓存复用;提示词前缀本身不受影响。
+切换路由可能减少提供方侧后续请求的缓存复用,或使其失效;提示词前缀本身不受影响。
 
-## Known Limitations and Deferred Work
+## 已知限制与暂缓事项
 
-- **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择折入会话创建的通道(host `targetFor` 处的种子序注释记录了该层未来的落点)。
-- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
-- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
+- **无创建期选择**——两个入口都面向既有会话的 agent(智能体);没有将草稿阶段的模型选择纳入会话创建的通道(host 的 `targetFor` 中的种子顺序说明了该层未来的落点)。
+- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
+- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。

+ 2 - 2
packages/client/ui-models/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
+#   pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
 README.md: 13f51d5338affd65d0705cec6a3b4ef78a534f0f
-README.zh.md: 466505beb27c729246afe04e6378235b91d072cf
+README.zh.md: 90f4eb5959e105178844c7bd5b07596aff81e706

+ 1 - 1
packages/client/ui-models/README.zh.md

@@ -10,7 +10,7 @@
 
 #### KV Cache 影响
 
-无;该包既不组装也不发送提供方请求。
+无;该包(package)既不组装也不发送提供方请求。
 
 ## 已知限制与暂缓事项
 

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 99d5f8b238ce78d83de0b5247d194b308dd6fac0
-README.zh.md: 468aa75e88580d6ed5e78666ad34c18b22a85dd3
+README.md: 3ff2717af7eeb6ef7f85c24456c7fe23b09d0faa
+README.zh.md: 56c4e9f1dae3eee1dc7ea64616e2ed4d536928e2

Разница между файлами не показана из-за своего большого размера
+ 3 - 1
packages/client/ui-primitives/README.md


Разница между файлами не показана из-за своего большого размера
+ 3 - 1
packages/client/ui-primitives/README.zh.md


+ 1 - 0
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",
     "mdast-util-from-markdown": "^2.0.3",
     "mdast-util-gfm": "^3.1.0",

+ 3 - 1
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<HTMLButtonElement>) {
   if (!onClick) {

+ 2 - 2
packages/client/ui-primitives/src/StateDot.tsx

@@ -23,8 +23,8 @@ const MATRIX_CELLS: readonly (readonly [number, number])[] = [
  */
 export function StateDot({ state, size = 10, className }: {
   state: StateDotState
-  size?: number
-  className?: string
+  size?: number | undefined
+  className?: string | undefined
 }) {
   if (state === 'ongoing') {
     return (

+ 152 - 0
packages/client/ui-primitives/src/TerminalBlock.module.css

@@ -0,0 +1,152 @@
+/* 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;
+  /* The card's own left inset, holding the run-state dot in a column of its own
+     so it never competes with the commands for horizontal space. */
+  --dsl-terminal-gutter: 30px;
+
+  position: relative;
+  margin: 16px 0;
+  /* The gutter is the card's OWN padding, not a margin: every consumer rewrites
+     `margin` wholesale (each render site sets its own indent), which silently
+     cancelled the reservation and let the dot fall outside the card into a
+     container that clips it. Owning the reservation here keeps the invariant
+     with the component that depends on it. */
+  padding-left: var(--dsl-terminal-gutter);
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: var(--dsl-terminal-radius);
+}
+
+/* Top-aligned: the status pill and copy control stay on the first prompt row
+   however many command lines the card carries. */
+.header {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  /* Pulled back across the card's gutter padding so the banner background and
+     its top-left radius span the FULL surface, then re-inset by the same amount
+     so the prompt text and the dot keep their positions. A plain block child
+     only reaches the content box, which left the gutter column painted in the
+     body color and drew the card's top-left corner in it — invisible in the
+     light theme, where banner and body share a token, and visible in the dark
+     one, where they do not. */
+  margin-left: calc(-1 * var(--dsl-terminal-gutter));
+  padding: 9px 14px 9px var(--dsl-terminal-gutter);
+  background: var(--dsw-alias-markdown-code-block-banner);
+  border-top-left-radius: var(--dsl-terminal-radius);
+  border-top-right-radius: var(--dsl-terminal-radius);
+}
+
+/* One row per command line. The prompt column is the only element allowed to
+   shrink; the status pill and the copy control keep their intrinsic width. */
+.prompt {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  flex: 1;
+  font: var(--dsw-font-markdown-code-block);
+}
+
+.promptLine {
+  position: relative;
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  min-width: 0;
+  line-height: var(--dsl-terminal-line-height);
+}
+
+/* Out of flow inside the card's own gutter padding, so the reservation and the
+   dot move together and no consumer margin can pull them apart; the dot neither
+   indents its command nor depends on the command's text metrics to line up.
+   Centered against the row's line box, not the code font's baseline. */
+.runState {
+  position: absolute;
+  left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+/* The dot is aria-hidden; this is its text label for assistive technology. */
+.runStateLabel {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+
+.cwd {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
+   `nowrap` collapses the repeated spaces, tabs, and alignment of an indented
+   continuation. Both hold the single row and the ellipsis. */
+.command {
+  min-width: 0;
+  color: var(--dsw-alias-label-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: pre;
+}
+
+.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 12px 0;
+  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 12px 0;
+  font: var(--dsw-font-markdown-code-block);
+  color: var(--dsw-alias-label-tertiary);
+}

+ 237 - 0
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -0,0 +1,237 @@
+// TerminalBlock: the terminal surface for a shell command and its output —
+// prompt line (run-state dot + 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 { StateDot, type StateDotState } from './StateDot.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
+}
+
+/**
+ * Run-state indicator for the command, shown at the head of the prompt line so
+ * the card states whether the command is still running without the reader
+ * having to infer it from the presence of output. Three of {@link StateDotState}'s
+ * four states are reachable: the running chase (the same
+ * indicator a running tool row's leading icon uses, so the row and its card
+ * never disagree), green for a clean settle, red for a signal or a non-zero
+ * exit — the same status distinction {@link statusText} draws for the pill. A
+ * settled command whose exit status never reached the view counts as a clean
+ * settle: the view says it finished and says nothing went wrong.
+ * @param running - the command has not settled.
+ * @param exitCode - settled exit code, when known.
+ * @param signal - settled terminating signal name, when known.
+ * @returns the dot's state and its text label, since the dot is aria-hidden.
+ */
+function runState(
+  running: boolean,
+  exitCode: number | undefined,
+  signal: string | undefined,
+): { state: StateDotState; label: string } {
+  if (running) return { state: 'ongoing', label: '运行中' }
+  if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
+  return { state: 'done', label: '已完成' }
+}
+
+/**
+ * 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 key={index} style={span.style}>{span.text}</span>)
+}
+
+/**
+ * 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 check runs on the
+  // PARSED lines rather than on the raw text, because a reset after the final
+  // newline (`line\n\x1b[0m`) leaves the string not ending in one while still
+  // producing a last line with nothing visible in it. A genuinely blank final
+  // line — the double newline — survives, since it has a real empty line before
+  // the terminator. The copy control still copies `text` untouched.
+  const lines = useMemo(() => {
+    const parsed = parseAnsiLines(text)
+    const last = parsed[parsed.length - 1]
+    const terminated = parsed.length > 1 && last !== undefined
+      && last.every(span => span.text === '')
+    return terminated ? parsed.slice(0, -1) : parsed
+  }, [text])
+  const [expanded, setExpanded] = useState(false)
+  const [copied, setCopied] = useState(false)
+
+  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 state = runState(running, exitCode, signal)
+  // A multi-line command gets one prompt row per line, so a two-command shell
+  // snippet reads as the two commands it is instead of collapsing into one
+  // ellipsized row. A trailing newline is a terminator, not an empty command.
+  const commandLines = useMemo(() => {
+    const body = command.endsWith('\n') ? command.slice(0, -1) : command
+    return body.split('\n')
+  }, [command])
+  // Read from the parsed lines the card actually renders, not from the raw text:
+  // output that is only escapes or control bytes (a lone reset, an OSC title, an
+  // erase) survives `text.trim()` yet parses to nothing visible. Judging it on
+  // the raw text drew an output box of blank rows plus a copy control for
+  // invisible bytes, and hid the placeholder that belongs there.
+  const empty = lines.every(line => line.every(span => span.text.trim() === ''))
+  const hidden = lines.length - maxLines
+  const capped = hidden > 0 && !expanded
+  // Same split arithmetic as the TUI transcript's collapsed tool card, so a
+  // command's head and tail slices agree between the two front ends.
+  const headLines = Math.ceil(maxLines / 2)
+  const tailLines = maxLines - headLines
+
+  return (
+    <div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
+      <div className={css.header}>
+        <div className={css.prompt}>
+          <span className={css.runStateLabel}>{state.label}</span>
+          {commandLines.map((line, index) => (
+            <div key={index} className={css.promptLine}>
+              {/* One dot for the card, on the first row: the exit status the
+                  view carries is the whole call's, and bash reports no
+                  per-command status, so a dot per row would assert a
+                  per-line outcome nothing here knows. */}
+              {index === 0 && <StateDot state={state.state} className={css.runState} />}
+              {/* The cwd labels the CALL, so only its first row carries it. The
+                  view knows one working directory — where the call started —
+                  and a later line may well run somewhere else (a `cd` in the
+                  command is enough), so repeating the label down the rows would
+                  assert a directory per line that nothing here knows. Later
+                  rows keep a bare `$` to stay aligned as prompts. */}
+              <span className={css.cwd}>
+                {index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
+              </span>
+              <span className={css.command}>{line}</span>
+            </div>
+          ))}
+        </div>
+        {status !== undefined && <Pill className={css.status}>{status}</Pill>}
+        {!running && !empty && (
+          <button type="button" className={css.copyButton} onClick={onCopy}>
+            {copied ? '复制成功' : '复制'}
+          </button>
+        )}
+      </div>
+      {!running && (empty
+        ? <div className={css.empty}>无输出</div>
+        : (
+          <div className={css.output}>
+            {(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
+              <div key={index} className={css.line}>{renderLine(line)}</div>
+            ))}
+            {hidden > 0 && (
+              <button
+                type="button"
+                className={css.expand}
+                aria-expanded={expanded}
+                aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
+                onClick={onToggle}
+              >
+                {expanded ? '收起' : `… 其余 ${hidden} 行`}
+              </button>
+            )}
+            {capped && lines.slice(lines.length - tailLines).map((line, index) => (
+              <div key={index} className={css.line}>{renderLine(line)}</div>
+            ))}
+          </div>
+        ))}
+    </div>
+  )
+}

+ 447 - 0
packages/client/ui-primitives/src/ansi.ts

@@ -0,0 +1,447 @@
+// 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<string, string> = {
+  '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<string, CSSProperties | undefined> = {
+  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, backspace and ESC
+ * survive: the first two for layout, backspace for the cursor replay, ESC
+ * for anser's CSI split.
+ */
+const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
+
+/**
+ * Lines whose cursor movements have to be replayed: a carriage return, a
+ * backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
+ * `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
+ * form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
+ */
+const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
+
+/** SGR sequences alone, for folding state through a line that needs no replay. */
+const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
+
+/** Terminal tab stop width; a tab advances to the next multiple of this. */
+const TAB_WIDTH = 8
+
+/**
+ * Combining marks and other zero-width code points: a terminal advances no
+ * column for them, so `e` + U+0301 occupies one cell and a two-column redraw
+ * covers both code points.
+ */
+const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
+
+/**
+ * Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
+ * CJK punctuation, and characters with emoji presentation. Text-presentation
+ * symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
+ * must stay out of this set.
+ */
+const WIDE_CHAR = new RegExp(
+  '\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
+  // Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
+  // width — `\u2713` (the check every progress line writes, this fixture
+  // included) advances one column, verified against a real terminal, so taking
+  // the whole block as wide misaligned exactly the output this card exists for.
+  + '|\\p{Emoji_Presentation}'
+  + '|[\\uff01-\\uff60\\u3000-\\u303e]',
+  'u',
+)
+
+/**
+ * Whether a character occupies two terminal columns (CJK, fullwidth forms,
+ * emoji). Covers the ranges a command's output realistically carries; a
+ * narrower guess would misalign the columns this card exists to preserve.
+ * @param char - one character from the output.
+ * @returns true when the terminal advances two columns for it.
+ */
+function isWide(char: string): boolean {
+  const code = char.codePointAt(0)
+  if (code === undefined || code < 0x1100) return false
+  return WIDE_CHAR.test(char)
+}
+
+/**
+ * A cell's graphic state, normalized. Held as fields rather than as the raw
+ * sequence history because a terminal tracks CURRENT state, not a transcript:
+ * accumulating sequences made each state boundary re-emit the whole chain, so
+ * output that switches color without a full reset emitted O(n^2) characters
+ * (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
+ * the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
+ * `24`, `27`, `29` — actually close their attribute instead of appending to it.
+ */
+interface SgrState {
+  fg: string
+  bg: string
+  /** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
+  attrs: readonly string[]
+}
+
+/** The default state: no color, no attributes. */
+const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
+
+/** Attribute closers, mapped to the opener parameters each one turns off. */
+const ATTR_CLOSERS: Record<string, readonly string[]> = {
+  22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
+}
+
+/**
+ * Fold one SGR sequence's parameters into the state it produces.
+ * @param state - state in force before the sequence.
+ * @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
+ * @returns the state the sequence leaves in force.
+ */
+function foldSgr(state: SgrState, params: string): SgrState {
+  const codes = params === '' ? ['0'] : params.split(';')
+  let next = state
+  for (let index = 0; index < codes.length; index++) {
+    const code = String(codes[index])
+    if (code === '' || code === '0') { next = SGR_NONE; continue }
+    // Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
+    // consume their own arguments, so they are taken whole.
+    if (code === '38' || code === '48') {
+      const kind = codes[index + 1] ?? ''
+      const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
+      const value = codes.slice(index, index + span + 1).join(';')
+      next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
+      index += span
+      continue
+    }
+    const closes = ATTR_CLOSERS[code]
+    if (closes !== undefined) {
+      next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
+      continue
+    }
+    const numeric = Number(code)
+    if (code === '39') { next = { ...next, fg: '' }; continue }
+    if (code === '49') { next = { ...next, bg: '' }; continue }
+    if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
+    if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
+    if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
+  }
+  return next
+}
+
+/**
+ * Render a state as the one canonical sequence that establishes it from the
+ * default, so a boundary emits a bounded string no matter how the state was
+ * reached.
+ * @param state - the state to open.
+ * @returns the SGR sequence, or the empty string for the default state.
+ */
+function openSgr(state: SgrState): string {
+  const codes = [...state.attrs]
+  if (state.fg !== '') codes.push(state.fg)
+  if (state.bg !== '') codes.push(state.bg)
+  return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
+}
+
+/** Whether two states are the same, so a boundary is only emitted on a change. */
+function sameSgr(a: SgrState, b: SgrState): boolean {
+  return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
+    && a.attrs.every((attr, index) => attr === b.attrs[index])
+}
+
+/**
+ * Replay one line's cursor movements the way a terminal paints it, into a
+ * column buffer. Carriage return and backspace only MOVE the cursor — neither
+ * erases anything — so what a reader sees is whatever each column last had
+ * written to it. That distinction is the whole point of doing this as a buffer
+ * rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
+ * shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
+ * because nothing ever overwrote the `c`.
+ *
+ * A CSI sequence occupies no column; it changes the state that the NEXT writes
+ * are stamped with, which is how a terminal stores color per cell. `red bad`
+ * then three backspaces then `ok` therefore shows `okd` with the `d` still red:
+ * `ok` overwrote two cells and the third kept the state it was written with.
+ * The columns are re-emitted as runs, so anser sees that same styling.
+ * @param line - one output line, still carrying its CSI sequences.
+ * @param entrySgr - SGR state in force when the line begins, since a newline
+ *   does not reset it.
+ * @returns the line as the terminal would have it after every movement, plus the
+ *   SGR state at its end for the next line to enter with.
+ */
+function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
+  // Same shape anser splits on, so a sequence is one unit here as well.
+  const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
+  /** Per column: the state in force when it was written, and its character. */
+  const columns: (Cell | undefined)[] = []
+  let cursor = 0
+  // State is tracked exactly as a terminal tracks it: each cell is stamped with
+  // whatever was in force at the moment of the write, so a later redraw cannot
+  // restyle the cells it does not reach. It enters carrying the previous line's
+  // state, since a newline does not reset it.
+  let sgr = entrySgr
+  let at = 0
+
+  /** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
+  const clear = (index: number, fill: string): void => {
+    const cell = columns[index]
+    if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
+    else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
+      columns[index + 1] = { sgr, char: fill }
+    }
+    columns[index] = { sgr, char: fill }
+  }
+
+  const consume = (chunk: string): void => {
+    for (const char of chunk) {
+      if (char === '\r') { cursor = 0; continue }
+      if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
+      if (char === '\t') {
+        // A tab advances to the next 8-column stop, leaving the cells it skips
+        // as they were — which is how a redraw can leave a tabbed column
+        // standing. Column alignment is the whole point of this card.
+        const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
+        for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
+        continue
+      }
+      if (ZERO_WIDTH.test(char)) {
+        // No column of its own: it attaches to the cell already written, so a
+        // redraw that covers that cell covers the mark with it. With no cell to
+        // attach to (line start, or straight after a redraw to column 0) a
+        // terminal shows nothing rather than a lone accent.
+        const base = cursor > 0 ? columns[cursor - 1] : undefined
+        if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
+        continue
+      }
+      // Writing over either half of a wide pair blanks the other half, since a
+      // terminal cannot leave one cell of a two-cell glyph standing.
+      clear(cursor, ' ')
+      columns[cursor] = { sgr, char }
+      cursor++
+      // A wide character occupies two columns; the trailing one is a spacer,
+      // marked so that overwriting the lead cell leaves a blank behind instead
+      // of closing the gap and shifting everything after it left.
+      if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
+    }
+  }
+
+  for (const match of line.matchAll(csi)) {
+    consume(line.slice(at, match.index))
+    at = match.index + match[0].length
+    // Both groups are mandatory in the pattern, so destructuring types them as
+    // strings without a fallback that could never run.
+    const params = String(match[1])
+    const final = String(match[2])
+    if (final === 'K') {
+      // Erase in line: the fixed companion of `\r` in every spinner and progress
+      // bar. Without it a shorter redraw leaves the previous frame's tail
+      // standing, which is text the terminal never showed. `1` blanks from the
+      // line start THROUGH the cursor column (inclusive, per the CSI spec)
+      // rather than dropping those cells, since the cursor does not move and a
+      // later write can still land past them. Only the FIRST parameter selects
+      // the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
+      const mode = String(params.split(';')[0])
+      if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
+      else columns.length = mode === '2' ? 0 : cursor
+      continue
+    }
+    // Only SGR carries graphic state; every other final byte is a cursor or
+    // erase action that must not affect a cell's style.
+    if (final !== 'm') continue
+    sgr = foldSgr(sgr, params)
+  }
+  consume(line.slice(at))
+
+  // Re-emit the columns, opening a run only where its state changes, so anser
+  // sees the same styling a terminal shows. Each boundary emits ONE canonical
+  // sequence for the state it opens, which is what keeps the output linear in
+  // the number of cells however the state was reached.
+  let out = ''
+  let active = entrySgr
+  for (let index = 0; index < columns.length; index++) {
+    const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
+    if (!sameSgr(column.sgr, active)) {
+      if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
+      out += openSgr(column.sgr)
+      active = column.sgr
+    }
+    // A spacer still holds its column. While its lead cell survives, the wide
+    // glyph spans both and the spacer emits nothing; once a later write replaced
+    // that lead, the terminal blanks the spacer instead of closing the gap, so
+    // emitting nothing would shift everything after it one column left.
+    const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
+    out += column.spacer === true && !leadIntact ? ' ' : column.char
+  }
+  // Converge to the state the SCAN ended in, not the last written cell's: a
+  // sequence after the final write (the `\x1b[0m` closing a colored line) changes
+  // no cell yet still ends the run, and it has to reach both the DOM and the
+  // next line. Without this a line ending in a reset leaked its color onward.
+  if (!sameSgr(active, sgr)) {
+    if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
+    out += openSgr(sgr)
+  }
+  return { text: out, sgr }
+}
+
+/** One replayed column: the state it was written with, and its character. */
+interface Cell {
+  sgr: SgrState
+  char: string
+  /** The trailing half of a wide character's two-column pair. */
+  spacer?: boolean
+}
+
+/**
+ * Replay every line's cursor movements. A `\r` that only terminates a CRLF line
+ * is dropped first, so those lines keep their text instead of being redrawn onto
+ * themselves. SGR state threads across lines: a newline does not reset it, so a
+ * run opened before a redraw still colors the lines after it.
+ * @param text - output text, already free of OSC and non-CSI escapes.
+ * @returns the text with each line painted as the terminal would.
+ */
+function applyCursorMovements(text: string): string {
+  const replayed: string[] = []
+  let sgr = SGR_NONE
+  for (const raw of text.split('\n')) {
+    const line = raw.replace(/\r+$/, '')
+    if (NEEDS_REPLAY.test(line)) {
+      const result = replayLine(line, sgr)
+      replayed.push(result.text)
+      sgr = result.sgr
+      continue
+    }
+    // No cursor movement: the line needs no column buffer, and painting one
+    // would allocate a cell per character of output this card never redraws —
+    // an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
+    // line that DOES replay enters with the right state.
+    replayed.push(line)
+    for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
+  }
+  return replayed.join('\n')
+}
+
+/**
+ * Remove every escape sequence and control character that carries no color,
+ * leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
+ * (carriage return, backspace) replay first, since their effect on the visible
+ * text must land before the characters that expressed them are dropped.
+ * @param text - raw command output.
+ * @returns text whose only remaining escapes are CSI sequences.
+ */
+function sanitize(text: string): string {
+  const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
+  return applyCursorMovements(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
+}

+ 48 - 0
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<boolean> {
+  // 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 */
+}

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -19,6 +19,8 @@ export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
 export { JsonTree } from './JsonTree.tsx'
 export type { JsonTreeProps } from './JsonTree.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'

+ 1 - 39
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<boolean> {
-  // 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])

+ 513 - 0
packages/client/ui-primitives/tests/ansi.spec.ts

@@ -0,0 +1,513 @@
+// 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'
+const BS = '\u0008'
+/** A combining acute accent: zero-width, so it takes no terminal column. */
+const ACCENT = '\u0301'
+
+/** 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<string, unknown>]>([
+    ['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('leaves the tail of a longer frame standing under a shorter redraw', () => {
+    // Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage
+    // return only moves the cursor, so the two columns the redraw never reaches
+    // still hold the frame beneath — truncating to the last `\r` would lose them.
+    expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined })
+    expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined })
+  })
+
+  it('clamps a backspace run at the line start rather than going negative', () => {
+    // More backspaces than characters: the cursor stops at column 0, so the
+    // following write simply overwrites from there.
+    expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined })
+  })
+
+  it('keeps SGR state in force across a redraw, as a terminal does', () => {
+    // Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED.
+    // A carriage return moves the cursor; it does not reset the graphic state,
+    // so the redraw inherits the color the discarded frame was written with.
+    expect(onlySpan(`${ESC}[31mgone\rkept`))
+      .toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } })
+  })
+
+  it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
+    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: backspaces', () => {
+  it('applies a backspace as the overwrite a terminal draws', () => {
+    // `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
+    expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
+  })
+
+  it('stops at the line start instead of eating the newline before it', () => {
+    expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
+      [{ text: 'ab', style: undefined }],
+      [{ text: 'cd', style: undefined }],
+    ])
+  })
+
+  it('treats a trailing backspace as a cursor move, not a delete', () => {
+    // Verified against a real terminal: `abc\b` still shows `abc`. Only a later
+    // write overwrites; a backspace with nothing after it erases nothing.
+    expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined })
+    // Same at a line boundary: the newline ends the line before any overwrite.
+    expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([
+      [{ text: 'abc', style: undefined }],
+      [{ text: 'def', style: undefined }],
+    ])
+  })
+
+  it('steps over an SGR sequence instead of erasing its bytes', () => {
+    // `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
+    // corrupt it and repaint the rest of the line with whatever the remainder
+    // parses as. The visible result is `aXY`, still red, with the reset intact.
+    expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
+      { text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+      { text: 'XY', style: undefined },
+    ]])
+  })
+
+  it('erases across a style boundary without dropping the styles between', () => {
+    // The backspace reaches back past the reset to the last printed character.
+    expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
+      { text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
+      { text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+
+  it('replays a redraw and a trailing backspace as pure cursor moves', () => {
+    // Verified against a real terminal: `old\rnew\b` shows `new`. The redraw
+    // repaints all three columns and the trailing backspace only moves the
+    // cursor left — nothing overwrites the `w`, so nothing is lost.
+    expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined })
+  })
+
+  it('overwrites only the columns the later write reaches, keeping the rest styled', () => {
+    // Verified against a real terminal: red `bad`, three backspaces, then `ok`
+    // shows `okd` — the cursor returned to column 0 and `ok` overwrote two of
+    // the three columns, so the untouched `d` keeps the run's red.
+    expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[
+      { text: 'ok', style: undefined },
+      { text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+})
+
+describe('parseAnsiLines: erase and column arithmetic', () => {
+  it('erases the rest of the line, the fixed companion of a redraw', () => {
+    // Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
+    // progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
+    // stands and the card shows text the terminal never displayed.
+    expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
+    // The parameterless form and `0` are the same erase.
+    expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
+  })
+
+  it('erases the whole line for the 2K form and to the cursor for 1K', () => {
+    expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
+    // 1K clears left of the cursor without moving it, so those columns read as
+    // blanks — verified in a real terminal, which shows `    |` for this input.
+    expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: '    |', style: undefined })
+  })
+
+  it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
+    // 2K clears the line but leaves the cursor where it was, so writing there
+    // leaves the columns before it unwritten — blanks, as a terminal shows.
+    expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: '    x', style: undefined })
+  })
+
+  it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
+    // Verified in a real terminal: `a\tb\rXY` shows `XY      b` — the `b` sits at
+    // column 8, which a two-character redraw cannot reach. Counting the tab as
+    // one column would have produced `XYb` and destroyed the alignment.
+    expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY      b', style: undefined })
+  })
+
+  it('counts a wide character as the two columns a terminal advances', () => {
+    // `中` occupies two cells, so a two-character redraw covers exactly it.
+    expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
+  })
+
+  it('does not accumulate a cursor or erase sequence into a cell style', () => {
+    // Only SGR carries graphic state. An erase folded into the style string
+    // would grow it per redraw and emit boundaries anser has to discard.
+    expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
+      { text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+})
+
+describe('parseAnsiLines: line-end state and column widths', () => {
+  it('closes a run whose reset lands after the last written cell', () => {
+    // Verified in a real terminal: `\x1b[32mdone\rok\x1b[0m` then `plain` shows
+    // `okne` GREEN and `plain` in the DEFAULT color. The reset changes no cell,
+    // so returning the last cell's state leaked green onto every later line —
+    // and this exact shape (`\r\x1b[K\x1b[32m✓ built\x1b[0m`) is what every
+    // build tool writes.
+    expect(parseAnsiLines(`${ESC}[32mdone\rok${ESC}[0m\nplain`)).toEqual([
+      [{ text: 'okne', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'plain', style: undefined }],
+    ])
+  })
+
+  it('erases through the cursor column for 1K, not up to it', () => {
+    // Verified in a real terminal: `abcd\b\x1b[1K|` shows `   |` — the `d` under
+    // the cursor is erased too, which the CSI spec calls inclusive.
+    expect(onlySpan(`abcd${BS}${ESC}[1K|`)).toEqual({ text: '   |', style: undefined })
+  })
+
+  it('gives a combining mark no column of its own', () => {
+    // Verified in a real terminal: `é` (e + U+0301) then `x`, redrawn with `YZ`,
+    // shows `YZ`. Counting the mark as a column left the `x` standing.
+    expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined })
+  })
+
+  it('drops a combining mark left with no cell to attach to by a redraw', () => {
+    // Verified in a real terminal: `ab` then CR then U+0301 then `x` shows `xb`.
+    // The redraw puts the cursor at column 0, so the mark has no preceding cell
+    // and the terminal shows nothing for it rather than a lone accent.
+    expect(onlySpan(`ab\r${ACCENT}x`)).toEqual({ text: 'xb', style: undefined })
+    // A mark with no movement on its line never reaches the replay at all: it
+    // is width business, not a cursor move, so it stays as authored.
+    expect(onlySpan(`${ACCENT}abc`)).toEqual({ text: `${ACCENT}abc`, style: undefined })
+  })
+
+  it('carries a colour opened after the last write onto the next line', () => {
+    // The mirror of the reset case, verified in a real terminal: `ab` CR `X` then
+    // `\x1b[31m` with nothing after it shows `Xb` UNSTYLED and the next line red.
+    // The scan ends styled while the last cell is not, so the convergence has to
+    // open the run at the line end for it to reach the following line.
+    expect(parseAnsiLines(`ab\rX${ESC}[31m\nnext`)).toEqual([
+      [{ text: 'Xb', style: undefined }],
+      [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
+    ])
+  })
+
+  it('blanks a wide character\'s spacer once its lead cell is overwritten', () => {
+    // Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide
+    // glyph's second cell becomes a blank rather than closing the gap, so the
+    // `x` keeps column 3.
+    expect(onlySpan('中x\rA')).toEqual({ text: 'A x', style: undefined })
+    // Covering both of its columns leaves no spacer behind.
+    expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
+  })
+
+  it('replays an erase whose parameters carry a semicolon', () => {
+    // The replay guard has to match the same CSI shape the parser accepts, or a
+    // form like `\x1b[1;2K` skips the replay and its erase never happens.
+    expect(onlySpan(`abcd${ESC}[1;2K|`)).toEqual({ text: '    |', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: bounded state and true widths', () => {
+  it('emits one canonical sequence per boundary however the state was reached', () => {
+    // Colors that never fully reset used to accumulate raw sequence history per
+    // cell, so every boundary re-emitted the whole chain: 3200 such cells
+    // produced 25 MB and eventually a RangeError. The state is normalized now,
+    // so the emitted text stays linear in the number of cells.
+    let input = ''
+    for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
+    const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
+    expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
+  })
+
+  it('closes an attribute with its closer instead of appending to the state', () => {
+    // `1` then `22` is bold then not-bold, which every chalk-based tool writes;
+    // appending both left the cell bold and grew the chain.
+    // Verified in a real terminal: the `22` closes the bold, so the `x` written
+    // after the redraw is PLAIN. Appending both left it bold and grew the chain.
+    expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
+      { text: 'x', style: undefined },
+    ]])
+    expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
+      { text: 'A', style: { fontWeight: 700 } },
+      { text: 'B', style: undefined },
+    ]])
+  })
+
+  it('folds extended colors, backgrounds and every attribute closer', () => {
+    // The 256-palette and truecolor forms consume their own arguments, so the
+    // fold has to take them whole rather than as separate codes.
+    expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'rgb(255, 135, 0)' } },
+    ]])
+    expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'rgb(10, 20, 30)' } },
+    ]])
+    // A background survives the same way, and `49` closes it.
+    expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: undefined },
+    ]])
+    // Each closer drops only its own attribute: `4` underline closed by `24`
+    // while the italic opened before it stays in force.
+    expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: { fontStyle: 'italic' } },
+    ]])
+    // `39` closes a foreground without touching the background.
+    expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
+    ]])
+  })
+
+  it('folds the remaining SGR shapes the model has to carry', () => {
+    // A 48-background in extended form, so the `48` arm and the `2`-span both run.
+    expect(parseAnsiLines(`${ESC}[48;2;1;2;3mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { backgroundColor: 'rgb(1, 2, 3)' } },
+    ]])
+    // A bright foreground and a bright background, the 90-97 / 100-107 arms.
+    expect(parseAnsiLines(`${ESC}[91mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'var(--dsw-alias-state-error-secondary)' } },
+    ]])
+    expect(parseAnsiLines(`${ESC}[101mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { backgroundColor: 'rgb(255, 85, 85)' } },
+    ]])
+    // An extended form with no recognized kind byte consumes nothing extra.
+    expect(parseAnsiLines(`${ESC}[38mA\r${ESC}[KB`)).toEqual([[{ text: 'B', style: undefined }]])
+    // Re-opening an attribute already in force does not duplicate it, and a bare
+    // `\x1b[m` resets exactly as `\x1b[0m` does.
+    expect(parseAnsiLines(`${ESC}[1m${ESC}[1mA${ESC}[mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: undefined },
+    ]])
+  })
+
+  it('treats a text-presentation symbol as one column', () => {
+    // Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
+    // check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
+    // misaligned exactly the progress output this card exists to show.
+    expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
+    // An emoji-presentation character is two, so the same redraw leaves a blank.
+    expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
+  })
+
+  it('clears a wide pair from either side, including through an erase', () => {
+    // Verified in a real terminal (`A x`): the redraw puts the cursor at column
+    // 0, the backspace clamps there, and writing `A` over the wide lead blanks
+    // its spacer rather than letting the `x` slide left.
+    expect(onlySpan(`\u4e2dx\r${BS}A`)).toEqual({ text: 'A x', style: undefined })
+    // An erase reaching the lead blanks its spacer through the same helper.
+    // Verified in a real terminal (`   |`): 1K blanks through the cursor column,
+    // so the wide glyph's two cells and the `x` all become blanks.
+    expect(onlySpan(`\u4e2dx${ESC}[1K|`)).toEqual({ text: '   |', style: undefined })
+  })
+
+  it('clears the lead when the write lands on the spacer itself', () => {
+    // Two backspaces from after `中x` stop ON the wide glyph's second cell;
+    // writing there blanks the lead through the spacer side of the pair clear,
+    // so the glyph cannot survive as half a character.
+    expect(onlySpan(`中x${BS}${BS}A`)).toEqual({ text: ' Ax', style: undefined })
+  })
+
+  it('keeps a surviving spacer as a blank when its lead was replaced by a spacer', () => {
+    // `好` written over the first glyph's spacer puts its own spacer on the
+    // second glyph's lead cell — a write that goes down without a pair clear.
+    // The second glyph's spacer survives with a dead lead and must emit a
+    // blank, or everything after it shifts one column left.
+    expect(onlySpan(`中中${BS}${BS}${BS}好`)).toEqual({ text: ' 好 ', style: undefined })
+  })
+
+  it('blanks both halves of a wide pair when either is overwritten', () => {
+    // A terminal cannot leave one cell of a two-cell glyph standing, so writing
+    // over the spacer clears the lead as well.
+    // Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
+    // the second glyph — writing the lead cell blanks its spacer, so the column
+    // stays occupied rather than collapsing.
+    expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: SGR across lines', () => {
+  it('carries active state past a newline, as a terminal does', () => {
+    // Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.
+    // A newline does not reset the graphic state, so a replayed line must hand
+    // its state to the next one instead of closing it off.
+    expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
+      [{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
+      [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
+    ])
+  })
+
+  it('tracks state through a line that needs no replay', () => {
+    // The middle line has no movement, so it is not replayed — but its own SGR
+    // still has to reach the line after it.
+    expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
+      [{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+    ])
+  })
+})
+
+describe('parseAnsiLines: runs spanning lines', () => {
+  it('carries one run\'s style onto every line it covers', () => {
+    expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
+      [{ 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 },
+    ]])
+  })
+})

+ 430 - 0
packages/client/ui-primitives/tests/terminal-block.spec.tsx

@@ -0,0 +1,430 @@
+// @vitest-environment jsdom
+// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
+// arms, the prompt line's run-state dot, 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 ?? '')
+}
+
+/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */
+function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } {
+  const dot = container.querySelector('[class*="_runState_"][data-state]')
+  return {
+    state: dot?.getAttribute('data-state') ?? null,
+    label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined,
+  }
+}
+
+/** The prompt rows as `<label><command>`, one per command line (the visual gap is CSS). */
+function promptRows(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_promptLine_"]')].map(row => (row.textContent ?? '').trim())
+}
+
+/** `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(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me" />)
+    expect(screen.getByText('~')).toBeTruthy()
+  })
+
+  it('shows only the last segment below home', () => {
+    render(<TerminalBlock command="ls" cwd="/Users/me/Documents" home="/Users/me" />)
+    expect(screen.getByText('Documents')).toBeTruthy()
+  })
+
+  it('ignores trailing separators on both the cwd and home', () => {
+    const view = render(<TerminalBlock command="ls" cwd="/Users/me/" home="/Users/me" />)
+    expect(view.getByText('~')).toBeTruthy()
+    view.rerender(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me/" />)
+    expect(view.getByText('~')).toBeTruthy()
+  })
+
+  it('drops trailing separators before taking the last segment', () => {
+    render(<TerminalBlock command="ls" cwd="/Users/me/Documents///" home="/Users/me" />)
+    expect(screen.getByText('Documents')).toBeTruthy()
+  })
+
+  it('takes the last segment when no home is known', () => {
+    render(<TerminalBlock command="ls" cwd="C:\\Users\\me\\Projects" />)
+    expect(screen.getByText('Projects')).toBeTruthy()
+  })
+
+  it('collapses a backslash home path to ~', () => {
+    render(<TerminalBlock command="ls" cwd="C:\\Users\\me" home="C:\\Users\\me" />)
+    expect(screen.getByText('~')).toBeTruthy()
+  })
+
+  it('falls back to the raw path when it has no segment', () => {
+    render(<TerminalBlock command="ls" cwd="/" home="/Users/me" />)
+    expect(screen.getByText('/')).toBeTruthy()
+  })
+
+  it('renders a plain $ with no cwd', () => {
+    render(<TerminalBlock command="ls" />)
+    expect(screen.getByText('$')).toBeTruthy()
+  })
+
+  it('renders the command verbatim after the label', () => {
+    render(<TerminalBlock command="git log --oneline | head -3" cwd="/Users/me/app" />)
+    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(<TerminalBlock command="sleep 5" running output="partial" />)
+    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(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
+    expect(screen.getByText('信号 SIGINT')).toBeTruthy()
+  })
+
+  it('settled with whitespace-only output shows the dimmed placeholder', () => {
+    const view = render(<TerminalBlock command="true" output={'  \n '} exitCode={0} />)
+    expect(view.getByText('无输出')).toBeTruthy()
+    expect(view.queryByRole('button', { name: '复制' })).toBeNull()
+  })
+
+  it('settled with absent output shows the placeholder', () => {
+    render(<TerminalBlock command="true" exitCode={0} />)
+    expect(screen.getByText('无输出')).toBeTruthy()
+  })
+
+  it('settled with an empty string shows the placeholder', () => {
+    render(<TerminalBlock command="true" output="" exitCode={0} />)
+    expect(screen.getByText('无输出')).toBeTruthy()
+  })
+
+  it('treats output that renders nothing visible as empty', () => {
+    // A lone reset, an OSC title, an erase: all survive `text.trim()` yet parse
+    // to nothing. Judging emptiness on the raw text drew a box of blank rows
+    // plus a copy control for invisible bytes, and hid the placeholder.
+    const view = render(<TerminalBlock command="true" output={`${ESC}[0m`} exitCode={0} />)
+    expect(view.getByText('无输出')).toBeTruthy()
+    expect(view.queryByText('复制')).toBeNull()
+    view.rerender(<TerminalBlock command="true" output={`${ESC}]0;title${ESC}\\`} exitCode={0} />)
+    expect(view.getByText('无输出')).toBeTruthy()
+  })
+
+  it('merges className onto the wrapper', () => {
+    const view = render(<TerminalBlock command="ls" className="x" output="a" />)
+    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(<TerminalBlock command="ls" output={'a\nb\n'} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b'])
+  })
+
+  it('drops the output terminator even when a reset follows the final newline', () => {
+    // `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed
+    // line holds nothing visible — a common shape, since tools close their color
+    // after the last line. Judging the terminator on the raw text added a blank
+    // row and inflated both the card height and the collapse count.
+    const view = render(<TerminalBlock command="ls" output={`a\nb\n${ESC}[0m`} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b'])
+  })
+
+  it('keeps a genuinely blank final line when the output ends with two newlines', () => {
+    const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b', ''])
+  })
+
+  it('renders ANSI runs as styled spans and plain text bare', () => {
+    const view = render(<TerminalBlock command="ls" output={`${ESC}[31mbad${ESC}[39m ok`} />)
+    // Scoped to a line: the prompt line's run-state dot is a styled span too.
+    const span = view.container.querySelector('[class^="_line_"] 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(<TerminalBlock command="ls" output={'plain one\nplain two\n'} />)
+    expect(view.container.querySelectorAll('[class^="_line_"] span')).toHaveLength(0)
+  })
+})
+
+describe('TerminalBlock status pill', () => {
+  it('renders no pill for a clean exit', () => {
+    const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
+    expect(view.queryByText(/退出码|信号/u)).toBeNull()
+  })
+
+  it('renders no pill while the exit status is unknown', () => {
+    const view = render(<TerminalBlock command="ls" output="a" />)
+    expect(view.queryByText(/退出码|信号/u)).toBeNull()
+  })
+
+  it('renders the exit-code pill for a non-zero exit', () => {
+    render(<TerminalBlock command="false" output="a" exitCode={1} />)
+    expect(screen.getByText('退出码 1')).toBeTruthy()
+  })
+
+  it('renders the signal pill, which outranks the exit code', () => {
+    render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
+    expect(screen.getByText('信号 SIGKILL')).toBeTruthy()
+    expect(screen.queryByText(/退出码/u)).toBeNull()
+  })
+})
+
+describe('TerminalBlock run-state dot', () => {
+  it('shows the running chase and its running label while the command runs', () => {
+    const view = render(<TerminalBlock command="sleep 5" running />)
+    expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
+  })
+
+  it('shows the done dot for a clean settled exit', () => {
+    const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
+    expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
+  })
+
+  it('counts a settled command with no exit status as a clean settle', () => {
+    const view = render(<TerminalBlock command="ls" output="a" />)
+    expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
+  })
+
+  it('shows the error dot for a non-zero exit', () => {
+    const view = render(<TerminalBlock command="false" output="a" exitCode={1} />)
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+  })
+
+  it('shows the error dot for a signal, whatever the exit code says', () => {
+    const view = render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+  })
+
+  // The dot precedes the prompt label, which is what makes it read as the
+  // state OF this command rather than of the card's chrome.
+  it('places the dot ahead of the prompt label and the command', () => {
+    const view = render(<TerminalBlock command="ls" cwd="/srv/app" output="a" />)
+    const row = view.container.querySelector('[class^="_promptLine_"]')
+    expect([...row!.children].map(node => node.textContent)).toEqual(['', 'app', 'ls'])
+  })
+
+  // The cwd labels the call, not each line: a `cd` in the command moves later
+  // lines elsewhere, so repeating the label would state a directory per line
+  // that the view does not know.
+  it('labels only the first row with the cwd, leaving later rows a bare $', () => {
+    const view = render(<TerminalBlock command={'cd ~\nls'} cwd="/srv/app" output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['appcd ~', '$ls'])
+  })
+
+  it('gives a multi-line command one row per line', () => {
+    const view = render(<TerminalBlock command={'echo one\necho two'} output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
+  })
+
+  // A heredoc or an editor-authored command commonly ends in a newline; that
+  // terminator is not a further, empty command to draw a row for.
+  it('drops a trailing newline instead of drawing an empty final row', () => {
+    const view = render(<TerminalBlock command={'echo one\necho two\n'} output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
+  })
+
+  it('keeps a genuinely blank command line when the command ends with two newlines', () => {
+    const view = render(<TerminalBlock command={'echo one\n\n'} output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['$echo one', '$'])
+  })
+
+  // The exit status the view carries is the whole call's — bash reports no
+  // per-command status — so exactly one dot and one label are correct however
+  // many lines the command spans. A dot per row would assert, of a line that
+  // succeeded inside a failing call, that the line itself failed.
+  it('marks the call once, on the first row, never per line', () => {
+    const view = render(<TerminalBlock command={'true\nfalse\ntrue'} output="x" exitCode={1} />)
+    expect(view.container.querySelectorAll('[class*="_runState_"][data-state]')).toHaveLength(1)
+    expect(view.container.querySelectorAll('[class^="_runStateLabel_"]')).toHaveLength(1)
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+    const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
+    expect(rows[0]!.querySelector('[data-state]')).not.toBeNull()
+    expect(rows[1]!.querySelector('[data-state]')).toBeNull()
+    expect(rows[2]!.querySelector('[data-state]')).toBeNull()
+  })
+
+  it('keeps the running dot even while a settled-looking status pill is supplied', () => {
+    const view = render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
+    expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
+  })
+})
+
+describe('TerminalBlock height cap', () => {
+  it('renders every line and no expand control under the cap', () => {
+    const view = render(<TerminalBlock command="ls" output={body(4)} maxLines={4} />)
+    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(<TerminalBlock command="ls" output={`${body(4)}\n`} maxLines={4} />)
+    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(<TerminalBlock command="ls" output={body(10)} maxLines={4} />)
+    // 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(<TerminalBlock command="ls" output={body(5)} maxLines={1} />)
+    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(<TerminalBlock command="ls" output={body(DEFAULT_TERMINAL_MAX_LINES + 1)} />)
+    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(<TerminalBlock command="make" cwd="/Users/me/app" output={output} exitCode={2} />)
+    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(<TerminalBlock command="ls" output={output} maxLines={4} exitCode={0} />)
+    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(<TerminalBlock command="ls" output="a" />)
+    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<HTMLTextAreaElement>('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)
+  })
+})

+ 2 - 2
packages/client/ui-settings/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
+#   pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
 README.md: bb99f9b37927eec57650aa4025deb043b369c78e
-README.zh.md: fce11e2cf44fe6c1debe850df644b0114dbde5e3
+README.zh.md: 64b207aadfbcd7d25c005b3dcd5358013d53c173

+ 1 - 1
packages/client/ui-settings/README.zh.md

@@ -10,7 +10,7 @@
 
 #### KV Cache 影响
 
-无;该包既不组装也不发送提供方请求。
+无;该包(package)既不组装也不发送提供方请求。
 
 ## 已知限制与暂缓事项
 

+ 2 - 2
packages/client/ui-sidebar/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
+#   pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
 README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9
-README.zh.md: 1ef636dbd00c894c8312ab1fbfa9a96af45956f0
+README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d

+ 6 - 6
packages/client/ui-sidebar/README.zh.md

@@ -2,11 +2,11 @@
 
 [English](README.md) | 中文
 
-侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的 Session 显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。契约:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
+侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。契约:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
 
-New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端 Session 为目标。Workspace Intent 不会出现在侧边栏中。
+New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。
 
-`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` hook、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
+`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
 
 页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。
 
@@ -18,10 +18,10 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work
 
 #### KV Cache 影响
 
-无;该包既不组装也不发送提供方请求。
+无;该包(package)既不组装也不发送提供方请求。
 
 ## 已知限制与暂缓事项
 
-- **状态点只有两种实时数据状态(running/none)**:done/error/amber 数据源随 P-II 审批与通知到来;四色原语已经接线
+- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入
 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。
-- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达主
+- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。

+ 2 - 2
packages/client/ui-skill/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
+#   pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
 README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7
-README.zh.md: 368171a43ef3a449049542cd227459f82ec43086
+README.zh.md: ed582128246a62297f555f8abe09f427cb9d256a

+ 7 - 7
packages/client/ui-skill/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-skill(技能)引用 source 的浏览器半侧:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话恒为 agent-backed,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 出生的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
+skill(技能)引用 source 的浏览器:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
 
 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
 
@@ -12,20 +12,20 @@ skill(技能)引用 source 的浏览器半侧:把 `/` 触发的 `skill` so
 
 ### 用户提示词中的 skill 引用文本
 
-#### 模型所见
+#### 模型看到的内容
 
 被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且不确定:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。
 
 #### Token 影响
 
-有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和候选拉取增加零模型 token。
+有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。
 
 #### KV Cache 影响
 
-仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
+仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包(package)绝不改写较早的请求 token。
 
 ## 已知限制与暂缓事项
 
-- **skill 加载不确定**:引用是协作线索,不是保证;模型可能忽略它。命中率被证明不足时的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;wire 上的文本形状不会改变。
-- **首次击键可能与预热竞速**:scope 出生的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
-- **文本即真身**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。
+- **skill 加载不确定**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。
+- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
+- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。

+ 1 - 1
packages/client/ui-slash/README.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
 README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
-README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
+README.zh.md: 20770f37f33c4a8a94486b116856b41bedec913e

+ 6 - 6
packages/client/ui-slash/README.zh.md

@@ -2,25 +2,25 @@
 
 [English](README.md) | 中文
 
-输入触发线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
+输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
 
 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
 
-MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
+MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
 
 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
 
 ## 模型体验
 
-无。触发线只是浏览器呈现——pick 产出 `CommandClaim`/`ReferenceInsert` 数据,其模型可见后果(host 命令执行;插入的引用文本随普通提示词发送)由消费方的 host 包与输入状态机包拥有。
+无。触发流水线只是浏览器呈现——pick 产出 `CommandClaim`/`ReferenceInsert` 数据,其模型可见后果(宿主命令执行;插入的引用文本随普通提示词发送)由消费方的 host 包与输入状态机包拥有。
 
 #### KV Cache 影响
 
-无;该包既不组装也不发送提供方请求。
+无;该包(package)既不组装也不发送提供方请求。
 
 ## 已知限制与暂缓事项
 
 - **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
-- **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;接到设计系统图标枚举(iconFile 五变体家族)的接线等该枚举交付后落地
+- **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;与设计系统图标枚举(iconFile 五变体家族)的接入将在该枚举交付后完成
 - **overlay 的 SlotMap 合并归属与 slot 所有权分离**:`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义(锚点、children 声明、生命周期)留在 ui-conversation;依赖方向(ui-conversation → ui-slash)迫使这一拆分,未来依赖关系调整时应重新审视。
-- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/subagent 时可以接受,业务 source 加入后需重新审视。
+- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill(技能)/subagent 时可以接受,业务 source 加入后需重新审视。

+ 2 - 2
packages/client/ui-slots/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
+#   pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md
 README.md: ed6f052b3a47e08d693928b6763e32427b829467
-README.zh.md: 8f15352d09a33a862203507ac89841da91a43e59
+README.zh.md: 17c3cbb28defe0c9bc66df417976984be3955b53

+ 5 - 5
packages/client/ui-slots/README.zh.md

@@ -2,24 +2,24 @@
 
 [English](README.md) | 中文
 
-Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。React 类型仅在运行时使用,该包不依赖 React,也不依赖 cordis。
+Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型;该包(package)不依赖 React,也不依赖 Cordis。
 
 一次 `register({ name, children?, store?, inject?, ...kind }, Component)` 调用会向已声明 slot 贡献一个组件,同时声明子 slot(声明 = 渲染授权 = 运行时规范,三者共用一张表)、store seat 以及注册方的业务表层。组件会在调用点依据 `ComposedProps` 接受检查;该类型是四个 share 的交集,每个 share 都从各自的唯一真源派生:
 
 | share | 类型 | 来源 |
 |---|---|---|
-| runtime | `PropsRuntime<K>` | SlotMap 配置项:`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat |
+| runtime | `PropsRuntime<K>` | SlotMap 条目:`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat |
 | child render | `PropsRenderSlots<S>` | register 调用的 `children` key 集合(静态缩窄的 `renderSlot`) |
 | store | `PropsStore<H>` | 已声明 handle:`useStore` selector hook + 移除 draft 的 `actions` |
 | business | `I` | 从 `inject` factory 返回值推断 |
 
-chain-kind slot 会反转键控路由:配置项自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector(另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其配置项,并成为组件的 `matched` prop;全部返回 null 时则使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。
+chain-kind slot 会反转键控路由:条目自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector(另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其条目,并成为组件的 `matched` prop;全部返回 null 时则使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。
 
 标准工具包接口(`SessionStandardProps`、`GlobalStandardProps`)在这里声明为空,由 runtime 包合并(与 SlotMap key 相同的 declare-merge 模式)。renderer 会把运行时 Session 和 Workspace observable source 绑定为 selector hook。Inject factory 参数从声明派生(`InjectParams`):Session slot 获得 `sessionId`;声明 store 时追加 baked `actions`;没有其他参数,数据访问位于 apply 闭包的 ctx 中。
 
 store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。
 
-`SlotCore` 在构造时播种先验的 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。配置项的 disposer 会递归折叠其声明的子 slot:账本行、贡献和 store 挂载都沿同一生命周期轴消失。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
+`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
 
 ## 模型体验
 
@@ -31,5 +31,5 @@ store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 st
 
 ## 已知限制与暂缓事项
 
-- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用配置项→记录反向引用改进。
+- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用条目→记录反向引用改进。
 - **`__renders` 幻象锚点在 `PropsRenderSlots` 上可见**:这是与类型链设计的 `__accepts` 相同且已接受的噪声;泛型方法签名在 key 联合之间比较宽松,因此必须依靠逆变标记强制执行「组件 key 集合 ⊆ children 声明」。

+ 2 - 2
packages/client/ui-subagent/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
+#   pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
 README.md: 7a70add139eae7bc507469b4fe7170359efdec31
-README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a
+README.zh.md: 4ff79780fd33a47a0a45695ab9feda15cd1763cd

Некоторые файлы не были показаны из-за большого количества измененных файлов