Selaa lähdekoodia

fix(web): render terminal cards for PTC bash subcalls

Tianyi Cui 1 kuukausi sitten
vanhempi
sitoutus
46a08db381
20 muutettua tiedostoa jossa 178 lisäystä ja 71 poistoa
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
  5. 17 13
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
  6. 17 13
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.i18n.yaml
  8. 29 0
      .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md
  9. 29 0
      .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml
  11. 2 2
      .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md
  12. 2 2
      .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md
  13. 29 8
      apps/web/tests/ptc-round.e2e.ts
  14. 2 2
      packages/client/ui-tool/README.i18n.yaml
  15. 2 2
      packages/client/ui-tool/README.md
  16. 2 2
      packages/client/ui-tool/README.zh.md
  17. 4 5
      packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
  18. 7 0
      packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
  19. 14 12
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  20. 8 2
      snapshots/web/ptc-round/ui.expected.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
-2026-07-20-canonical-tool-output-contract.md: 52b56a44a747a07e13e05511d6d40a85fcd9696d
-2026-07-20-canonical-tool-output-contract.zh.md: a78defb2b45143d46fed2bedf760df12744621a5
+2026-07-20-canonical-tool-output-contract.md: f2c17325f77b93675086c39dd5a7693854b8521a
+2026-07-20-canonical-tool-output-contract.zh.md: d81c1730aab66df2dcf4eea4515f260df917ffb4

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md

@@ -34,7 +34,7 @@ type ToolExecutionResult =
 
 `tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value.
 
-Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
+Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw arguments and rendered content without that metadata. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
 
 The first-party tools preserve their existing Native text while returning domain DTOs:
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md

@@ -34,7 +34,7 @@ type ToolExecutionResult =
 
 `tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。
 
-规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
+规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据。Client 可以从原始参数与渲染后的内容派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需这些元数据。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
 
 第一方工具在保持现有 Native 文本不变的同时返回领域 DTO:
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: ada444ab06bebd3e50018cea4a8dcde6b13956c4
-2026-08-23-client-derived-tool-presentation.zh.md: 8c1d60d5ed11e79b4428c7245f0db9c6b91c8b2c
+2026-08-23-client-derived-tool-presentation.md: 7f62e99fe152875bfd04bc11c5a1d5dea9014caa
+2026-08-23-client-derived-tool-presentation.zh.md: 433d1efb4cded651b39452716399be1ca02c102d

+ 17 - 13
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md

@@ -22,6 +22,8 @@ The required result is one raw Session journal and one Client presentation owner
 
 ## Decision
 
+The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain.
+
 The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
 
 The Client Conversation layer continues to own tool call/result identity, pairing, lifecycle, Code Dispatch topology, and stable Chat Nodes. It does not interpret individual tool names or produce terminal, diff, read, search, or web component props.
@@ -49,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
 | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
 | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
 | Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
-| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements |
+| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements except the separately owned terminal exception below |
 | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
 
 ## Terminology
@@ -242,13 +244,13 @@ The Chat and Trajectory Tool Definitions read no views. They derive the followin
 
 ### Root and Code Dispatch subcalls
 
-Host presenter APIs describe top-level calls and results. Code Dispatch subcalls use the Generic, flattened Client presentation; recognizing a subcall name does not grant it a structured card.
+Host presenter APIs describe top-level calls and results. Code Dispatch subcalls retain Generic, flattened presentation for the diff, read, search, and web models covered here; supported terminal calls use the same eligibility rules as roots.
 
-Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The five structured card models accept only blocks without `parentCallId`, while existing renderers that intentionally support nested calls continue receiving the same child block.
+Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The diff, read, search, and web models accept only blocks without `parentCallId`; the terminal model and existing renderers that intentionally support nested calls accept child blocks.
 
-The Details panel delegates the selected block unchanged. The same card models observe `parentCallId` and keep a selected Code Dispatch child on the existing raw fallback, so the Details slot needs no placement field.
+The Details panel delegates the selected block unchanged. Shared card models apply the same terminal eligibility and nonterminal child restrictions in rows and Details, so the Details slot needs no placement field.
 
-The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` controls only the terminal, diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
+The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` restricts only the diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
 
 ### Missing call head
 
@@ -294,7 +296,7 @@ The title, kind, rawInput, content, and locations from Generic Host `presentCall
 
 ### Terminal card
 
-The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, existing `parentCallId`, and Session cwd.
+The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`.
 
 | Input | Preserved result |
 |---|---|
@@ -306,7 +308,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
 | settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card |
 | foreground `terminal_send` | terminal prompt and output |
 | background/error `terminal_send` | Generic result |
-| Code Dispatch child | current flattened Generic form |
+| Code Dispatch child | same terminal eligibility and fallback rules as a root call |
 
 Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body.
 
@@ -418,7 +420,7 @@ The fixture does not import Host tool packages to compute page presentation and
 | grep/glob | current grouped/path card, truncation, and recovery |
 | web_search/web_fetch | current source/summary card and raw body |
 | Todo/Question/Skill/Cordis | current specialized rows |
-| Code Dispatch subcall | current Generic/flattened form |
+| Code Dispatch subcall | terminal cards when eligible; diff/read/search/web remain Generic/flattened |
 | Chat and Details | identical card fields for the same call |
 | Trajectory | current identity, tree, selection, and details |
 | Deliverables | current successful-mutation chips and links |
@@ -526,7 +528,7 @@ This change does not promise to preserve differences expressed only through a Ho
 - search produces the pinned grouped/path card and recovery from metadata/content.
 - web produces the pinned sources/fetch summary from metadata/content.
 - unknown, malformed, error, missing-call, and missing-metadata cases remain Generic.
-- absent and present `parentCallId` cases prove that structured presentation does not reach Code Dispatch descendants.
+- absent and present `parentCallId` cases prove equal terminal eligibility and preserve Generic fallback for diff, read, search, and web descendants.
 - Chat and Details produce identical card fields for the same block.
 
 ### Deliverables
@@ -582,12 +584,12 @@ Changes to this decision use `dsh-pre-push-checks` to select commands for the fi
 - Result metadata passes byte-for-byte through the log and Remote to the Client.
 - Conversation assembles `ToolCallBlock` only from raw events.
 - `ToolCallBlock` contains no Host render-intent fields.
-- The five structured card models read only raw blocks, their existing `parentCallId`, and Session path facts.
+- The five structured card models read only raw blocks and Session path facts; only diff, read, search, and web use `parentCallId` to reject children.
 - Generic, Todo, Question, Skill, and Cordis rows remain unchanged.
 - Deliverables does not depend on render intent and preserves current paths.
 - Text, components, expanded content, states, links, and ordering for all first-party top-level tools remain unchanged.
 - Malformed, missing-metadata, error, orphan, and unknown-tool cases continue to fall back safely.
-- Code Dispatch subcalls remain Generic and flattened.
+- Code Dispatch diff, read, search, and web subcalls remain Generic and flattened; terminal subcalls follow root eligibility.
 - Chat, Details, and Trajectory behavior remains unchanged.
 - Existing Web browser expected outputs pass without refresh.
 - Host presenter APIs, implementations, and direct tests remain unchanged.
@@ -632,7 +634,7 @@ An on-demand RPC would turn one page read into N network calls and would still r
 
 ### Allow presentation enhancements
 
-The Client could produce more rich cards for Code Dispatch subcalls, missing call heads, or history whose Host presenter was unavailable. That would mix an ownership change with product behavior and prevent snapshots from proving equivalence, so this alternative is rejected.
+Bundling richer Code Dispatch cards, missing-call-head inference, or other historical presentation enhancements with the ownership change would prevent snapshots from proving equivalence. This decision rejects that coupling; the separately owned terminal exception below does not relax nonterminal child restrictions.
 
 ### Accept temporary Generic degradation
 
@@ -684,6 +686,8 @@ The absence of optional `view` is a prerelease wire-type decision shared by all
 
 ## Relationship to Existing Decisions
 
+[Nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) partially supersedes only the terminal child-card prohibition and its visual-equivalence requirement. This note remains active for raw-journal ownership, Client derivation, and the diff/read/search/web child restrictions.
+
 This note partially supersedes the implementation fact in [Client tool presentation ownership](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) that “card models receive Host views.” Its core decisions remain: `ui-tool` owns presentation, business plugins use keyed slots, and Conversation owns only lifecycle and topology.
 
 This note preserves [toolview dissolution](../../archived/architecture/2026-07-23-toolview-dissolution.md): the Client still has one slot registration model and does not restore `ToolViewRegistry`.
@@ -699,7 +703,7 @@ This note preserves result metadata from the [canonical tool output contract](20
 ## Deferred
 
 - A separate explicit decision may evaluate deleting Host presenters if they remain without production consumers; this decision does not prejudge it.
-- Specialized cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; this decision preserves current behavior.
+- Specialized diff, read, search, and web cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; terminal calls are covered by the linked partial supersession.
 - A third-party mutation tool that joins Deliverables requires a new Client-owned contribution; this decision does not create a registry for an absent consumer.
 - Distinct Client presentation for same-named providers first requires a stable, non-presentational identity; it must not restore per-page Host views.
 - If Client card-model performance needs measurement, an immutable-block microbenchmark can be added; the shipped architecture already prohibits scanning the Session window.

+ 17 - 13
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md

@@ -22,6 +22,8 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
 
 ## Decision
 
+下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。
+
 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
 
 Client Conversation 层继续负责工具调用与结果的 identity、配对、生命周期、Code Dispatch 拓扑和稳定 Chat Node。它不解释具体工具名称,也不生成 terminal、diff、read、search 或 web 组件 props。
@@ -49,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
 | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
 | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
 | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
-| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强 |
+| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强,下述独立决策的 terminal 例外除外 |
 | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
 
 ## 术语
@@ -242,13 +244,13 @@ Chat 和 Trajectory 的 Tool Definition 都不读取 view,而从事件生成
 
 ### Root 与 Code Dispatch 子调用
 
-Host presenter API 描述顶层 call/result。Code Dispatch 子调用使用 Generic/flattened Client 展示;Client 能识别子调用名称并不赋予它结构化卡片。
+Host presenter API 描述顶层 call/result。本决定覆盖的 diff、read、search 和 web model 对 Code Dispatch 子调用保留 Generic/flattened 展示;受支持的 terminal 调用使用与根调用相同的适用规则。
 
-Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。五类结构化 card model 只接受没有 `parentCallId` 的 block,原本有意支持嵌套调用的 renderer 则继续收到同一个 child block。
+Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。diff、read、search 和 web model 只接受没有 `parentCallId` 的 block;terminal model 与原本有意支持嵌套调用的 renderer 接受 child block。
 
-Details panel 原样委托选中的 block。同一组 card model 读取 `parentCallId`,让选中的 Code Dispatch child 保持现有 raw fallback,因此 Details slot 不需要 placement 字段。
+Details panel 原样委托选中的 block。共享 card model 在行与 Details 中应用相同的 terminal 适用规则和非 terminal 子调用限制,因此 Details slot 不需要 placement 字段。
 
-keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只控制本决定覆盖的 terminal/diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
+keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只限制本决定覆盖的 diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
 
 ### 缺失调用头
 
@@ -294,7 +296,7 @@ Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 
 
 ### Terminal 卡片
 
-Client terminal model 从工具名称、调用参数、结果 content、error、现有 `parentCallId` 与 Session cwd 派生现有 `TerminalBlock` props。
+Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。
 
 | 输入 | 保持的结果 |
 |---|---|
@@ -306,7 +308,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error、
 | persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card |
 | `terminal_send` 前台 | terminal prompt 与 output |
 | `terminal_send` background/error | Generic 结果 |
-| Code Dispatch child | 当前 flattened Generic 形态 |
+| Code Dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 |
 
 标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
 
@@ -418,7 +420,7 @@ fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 
 | grep/glob | 当前 grouped/path card、截断与 recovery |
 | web_search/web_fetch | 当前来源/摘要 card 与原始正文 |
 | Todo/Question/Skill/Cordis | 当前专用行 |
-| Code Dispatch subcall | 当前 Generic/flattened 形态 |
+| Code Dispatch subcall | 满足条件时显示 terminal 卡片;diff/read/search/web 保持 Generic/flattened |
 | Chat 与 Details | 同一调用使用相同 card fields |
 | Trajectory | 当前 identity、树、选择和 details |
 | Deliverables | 当前成功 mutation chips 与链接 |
@@ -526,7 +528,7 @@ Host registry 允许不同 scope 为同一 tool name 提供不同定义;Sessio
 - search 用 meta/content 得到已固定的 grouped/path card 与 recovery。
 - web 用 meta/content 得到已固定的 sources/fetch summary。
 - unknown、malformed、error、missing-call 与 missing-meta 继续 Generic。
-- `parentCallId` 缺失与存在的用例证明结构化展示不会到达 Code Dispatch descendant。
+- `parentCallId` 缺失与存在的用例证明 terminal 适用规则一致,并保留 diff、read、search 和 web descendant 的 Generic fallback。
 - Chat 与 Details 对同一 block 得到相同 card fields。
 
 ### Deliverables
@@ -582,12 +584,12 @@ Host registry 允许不同 scope 为同一 tool name 提供不同定义;Sessio
 - result meta 逐字节通过日志与 Remote 到达 Client。
 - Conversation 只从 raw event 组装 ToolCallBlock。
 - ToolCallBlock 不含 Host render-intent 字段。
-- 五类结构化 card model 只读 raw block、其现有 `parentCallId` 与 Session path facts。
+- 五类结构化 card model 只读 raw block 与 Session path facts;只有 diff、read、search 和 web 使用 `parentCallId` 拒绝子调用。
 - Generic、Todo、Question、Skill 与 Cordis 行行为不变。
 - Deliverables 不依赖 render intent 且保持当前 paths。
 - 所有第一方顶层工具的文本、组件、展开内容、状态、链接与排序不变。
 - malformed、missing-meta、error、orphan 与 unknown-tool 继续安全 fallback。
-- Code Dispatch 子调用保持 Generic/flattened。
+- Code Dispatch 的 diff、read、search 和 web 子调用保持 Generic/flattened;terminal 子调用遵循根调用适用规则。
 - Chat、Details 与 Trajectory 行为不变。
 - 现有 Web browser expected 无需刷新即可通过。
 - Host presenter API、实现与直接测试不变。
@@ -632,7 +634,7 @@ read 行结构、applied diff、search 分组、web sources 和有效 truncation
 
 ### 允许展示增强
 
-Client 可以为 Code Dispatch 子调用、缺失 call head 或 Host presenter 不可用的历史生成更多 rich card,但这会混淆 ownership 变化与产品行为,并使快照无法证明对等,因此拒绝。
+将更丰富的 Code Dispatch 卡片、缺失 call head 的推断或其他历史展示增强与所有权变更捆绑,会使快照无法证明对等。本决定拒绝这种捆绑;下述独立决策的 terminal 例外不放宽非 terminal 子调用限制。
 
 ### 接受临时 Generic 退化
 
@@ -684,6 +686,8 @@ optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型
 
 ## 与现有决策的关系
 
+[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)仅部分取代 terminal 子调用卡片禁令及其展示对等要求。本文继续负责原始 journal 所有权、Client 派生以及 diff/read/search/web 子调用限制。
+
 本文部分取代 [Client 工具展示所有权](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) 中“card model 接收 Host view”的实现事实;`ui-tool` 拥有展示、业务插件使用 keyed slot、Conversation 只拥有生命周期与拓扑的核心决定保持不变。
 
 本文保留 [toolview 溶解](../../archived/architecture/2026-07-23-toolview-dissolution.md) 的决定:Client 仍只有 slot 注册模型,不恢复 `ToolViewRegistry`。
@@ -699,7 +703,7 @@ optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型
 ## Deferred
 
 - Host presenter 若长期没有生产消费者,可由另一项明确决策评估删除;本决定不预判。
-- Code Dispatch 子调用若要专用卡片,需单独设计并更新可见快照;本决定保持现状。
+- Code Dispatch 子调用的 diff、read、search 和 web 专用卡片仍需独立设计并更新可见快照;terminal 调用由链接的部分取代决策负责。
 - 第三方 mutation tool 若要加入 Deliverables,需新增 Client-owned 贡献;本决定不为尚无消费者的扩展性建 registry。
 - 同名 provider 若要不同 Client 展示,需先定义稳定、非展示性的 identity;不得恢复按页 Host view。
 - Client card model 若需量化性能,可以增加 immutable-block 微基准;已交付架构禁止扫描 Session window。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.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/bug-fix/2026-09-05-nested-terminal-cards.md
+2026-09-05-nested-terminal-cards.md: 10e0c082650fe5a937aa8fba9f6080896fe8538f
+2026-09-05-nested-terminal-cards.zh.md: 2ad94f62657b78b5eaf6fde0033e73abee07997e

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md

@@ -0,0 +1,29 @@
+# Agent Note: Nested terminal cards
+
+Status: implemented
+
+English | [中文](2026-09-05-nested-terminal-cards.zh.md)
+
+## Problem
+
+A shell command dispatched through `run_code` carries the arguments and rendered output needed for a terminal card, but rejecting every block with `parentCallId` hides that presentation solely because the call is nested. The rejection also affects running prompts and selected-child Details.
+
+## Decision
+
+`terminalCardModel` applies the same eligibility checks to root and Code Dispatch calls, without rejecting `parentCallId`. Supported running and settled `bash`, `pwsh`, and `terminal_send` calls use the existing terminal card. Background calls, tool errors, malformed inputs, missing call heads, and unsupported result content retain generic fallback. Persistent shells remain eligible while running and generic when settled; a nonzero process exit remains terminal result data rather than a tool error.
+
+This partially supersedes only the terminal child-card prohibition in [Client-derived tool presentation](../architecture/2026-08-23-client-derived-tool-presentation.md). That note remains active for Client presentation ownership and the diff/read/search/web child restrictions. No Host presenter, event, schema, metadata, call-tree, or model-context change is required. The metadata and execution-local value decisions in [canonical tool output](../architecture/2026-07-20-canonical-tool-output-contract.md) and [PTC typed returns](../feature/2026-07-20-ptc-typed-tool-returns.md) remain intact; metadata omission does not prohibit Client-derived terminal cards.
+
+## Alternatives considered
+
+**Keep the blanket nested-call rejection.** Rejected because nesting does not remove the raw facts the terminal model already consumes. It hides usable shell output while the same call renders as a terminal at the root.
+
+**Enable every nested structured card.** Rejected because other card models have independent metadata requirements and child restrictions. This fix changes only terminal eligibility.
+
+## Consequences
+
+Rows and Details share terminal derivation for nested calls without a second renderer or presentation hint. Generic fallback and settled-persistent behavior remain separate from terminal-card eligibility. The parent-child relationship still controls tree placement, not terminal rendering.
+
+## Verification
+
+The [terminal card specs](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx) cover root/child eligibility, running and settled Details, and fallback cases. The [assembled Code Dispatch specs](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx) cover nested terminal rendering through the conversation tree. Browser replay owns the visible nested-card change; nonterminal child behavior remains outside this fix.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 嵌套 terminal 卡片
+
+Status: implemented
+
+[English](2026-09-05-nested-terminal-cards.md) | 中文
+
+## 问题
+
+经 `run_code` 分发的 shell 命令携带 terminal 卡片所需的参数与渲染输出,但对所有带有 `parentCallId` 的块一律拒绝,会仅因调用嵌套而隐藏这类展示。该拒绝也影响运行中的命令提示行与选中子调用的 Details。
+
+## 决策
+
+`terminalCardModel` 对根调用与 Code Dispatch 调用应用相同的适用检查,不因 `parentCallId` 拒绝调用。受支持的运行中与已完成的 `bash`、`pwsh` 和 `terminal_send` 调用使用现有 terminal 卡片。后台调用、工具错误、格式错误的输入、缺失的调用头和不受支持的结果内容保留通用回退。持久 shell 在运行中仍可使用 terminal,完成后使用通用展示;非零进程退出仍是 terminal 结果数据,而非工具错误。
+
+本文仅部分取代 [Client 派生工具展示](../architecture/2026-08-23-client-derived-tool-presentation.zh.md)中的 terminal 子调用卡片禁令。该文继续负责 Client 展示所有权及 diff/read/search/web 子调用限制。无需更改 Host 展示转换器、事件、schema、元数据、调用树或模型上下文。[规范工具输出](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)与 [PTC 类型化返回值](../feature/2026-07-20-ptc-typed-tool-returns.zh.md)中的元数据和执行期值决策保持不变;省略元数据不禁止 Client 派生 terminal 卡片。
+
+## 考虑过的替代方案
+
+**保留对嵌套调用的一律拒绝。** 不予采用,因为嵌套不会移除 terminal model 已消费的原始事实。这会隐藏可用的 shell 输出,而同一调用位于根时却可渲染为 terminal。
+
+**启用所有嵌套结构化卡片。** 不予采用,因为其他 card model 有独立的元数据要求与子调用限制。本修复只改变 terminal 适用性。
+
+## 后果
+
+行与 Details 对嵌套调用共享 terminal 派生,不增加第二个渲染器或展示提示字段。通用回退与已完成持久 shell 的行为仍独立于 terminal 卡片适用性。父子关系仍控制树中的位置,而非 terminal 渲染。
+
+## 验证
+
+[Terminal 卡片测试](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx)覆盖根/子调用适用性、运行中与已完成的 Details 以及回退情况。[组装后的 Code Dispatch 测试](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx)覆盖经对话树渲染的嵌套 terminal。浏览器回放负责验证可见的嵌套卡片变化;非 terminal 子调用行为不属于本修复。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md
-2026-07-20-ptc-typed-tool-returns.md: cc784ca8c9b1565135bc59c11ae0af973845fe5f
-2026-07-20-ptc-typed-tool-returns.zh.md: c4dd52d3c136569610beb8a65303809a1fe5fe4d
+2026-07-20-ptc-typed-tool-returns.md: 04ef9f7da4cd59ba07632684541dd6962008b810
+2026-07-20-ptc-typed-tool-returns.zh.md: c197d3131cc0f7fa326a9a47d945b2b7730f01c0

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md

@@ -75,7 +75,7 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu
 
 Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. This feature did not itself require a structural Session-format change; the released v0-to-v1 identity edge preserves these records, and replay still cannot recreate intermediate canonical program values.
 
-The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`.
+The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls; their canonical values never enter context. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw dispatch events without metadata. The outer `run_code` call produces the model-facing result and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`.
 
 ## Testing
 
@@ -112,5 +112,5 @@ The worker performs bounded-depth flat-wire transport and lossless validation bu
 - The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap.
 - Provider or executor acquisition limits may already have discarded source data before a canonical value reaches PTC mode.
 - Unsupported MCP output schemas fall back to `JsonValue`; admitted MCP images use the generic deferred projection, while audio and embedded-resource payloads remain diagnostic-only.
-- There is one result card per outer `run_code`, never per nested call.
+- Nested calls have no separate model-facing result; Client child cards do not add model context.
 - Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md

@@ -75,7 +75,7 @@ PTC mode 通过运行时请求中的 `{ name: "ToolCallError", memberNamePropert
 
 嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。该功能本身不要求结构性 Session 格式变更;已发布的 v0-to-v1 恒等边会保留这些记录,回放仍无法重建程序的规范中间值。
 
-不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的 spill 投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。
+不透明的 `exec.parent` token 用于标识嵌套调用。展示元数据以及通用或工具自有的 spill 投影都会跳过这些调用;它们的规范值永远不会进入上下文。Client 可以从原始分发事件派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需元数据。外层 `run_code` 调用产生面向模型的结果,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。
 
 ## 测试
 
@@ -112,5 +112,5 @@ worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损
 - 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;spill 无法恢复超出该上限后被拒绝的字节。
 - 提供方或执行器的采集上限可能在规范值到达 PTC mode 前就已丢弃部分源数据。
 - 不支持的 MCP 输出 schema 会回退为 `JsonValue`;已准入的 MCP 图片使用通用延后投影,而音频和嵌入资源载荷仍只提供诊断。
-- 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。
+- 嵌套调用没有独立的面向模型的结果;Client 子调用卡片不会增加模型上下文。
 - PTC mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。

+ 29 - 8
apps/web/tests/ptc-round.e2e.ts

@@ -8,7 +8,7 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
@@ -102,14 +102,35 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
     expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel closed', async () => {
+  it.skipIf(MODE === 'record')('expands the nested bash terminal inline before and after reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-details'))
-    const nest = page.locator('[data-subcalls]').first()
-    const frame = page.locator('[style*="grid-template-columns"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
-    await expandOwningTurnProcess(page, nest)
-    await nest.locator('[data-sample="bash"]').first().click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+    let liveTerminalAria: string | undefined
+    for (const reloaded of [false, true]) {
+      if (reloaded) {
+        const warningStart = tripwire.warnings.length
+        await page.reload({ waitUntil: 'load' })
+        acknowledgeReloadConnectionLoss(tripwire, warningStart)
+        await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
+      }
+      const nest = page.locator('[data-subcalls]').first()
+      const frame = page.locator('[style*="grid-template-columns"]').first()
+      expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+      await expandOwningTurnProcess(page, nest)
+      const row = nest.locator('[data-sample="bash"]').first()
+      await expect.poll(() => row.getAttribute('data-state')).toBe('ok')
+      await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
+      await row.click()
+      await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
+      const terminal = row.locator('xpath=..').locator('[data-terminal]')
+      await terminal.waitFor()
+      await terminal.getByText('echo CODE_ROUND_OK', { exact: true }).waitFor()
+      await terminal.getByText('CODE_ROUND_OK', { exact: true }).waitFor()
+      await expect.poll(() => terminal.locator('[data-state]').getAttribute('data-state')).toBe('done')
+      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+      const aria = await terminal.ariaSnapshot()
+      if (reloaded) expect(aria).toBe(liveTerminalAria)
+      else liveTerminalAria = aria
+    }
   })
 
   it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => {

+ 2 - 2
packages/client/ui-tool/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-tool/README.md
-README.md: 6db5018801ef3f60ca25d97d51edac1538bff133
-README.zh.md: c7df60acaeeefb1afbb12f1ed481260de62905d2
+README.md: 02c403133f7586217ed3397d9f496baedfcfb0a8
+README.zh.md: 0278e7fceca34d0e2b9334cfb33ae1a85fbdc27c

+ 2 - 2
packages/client/ui-tool/README.md

@@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo
 
 ### Built-in views
 
-This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground `bash`, `pwsh`, and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
 
 -----
 
@@ -62,7 +62,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
 ### Details and cards
 
 
-The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
+The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, and Session path facts. Diff, read, search, and web models also reject Code Dispatch children; terminal eligibility does not depend on `parentCallId`. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
 </details>
 
 -----

+ 2 - 2
packages/client/ui-tool/README.zh.md

@@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 ### 内置视图
 
-本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台 `bash`、`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 
 -----
 
@@ -62,7 +62,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 ### 详情与卡片
 
 
-本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
+本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata 与 Session 路径事实。diff、read、search 和 web model 还会拒绝 Code Dispatch 子调用;terminal 适用性不依赖 `parentCallId`。不受支持或格式错误的输入使用压平的工具结果文本。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
 </details>
 
 -----

+ 4 - 5
packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts

@@ -255,10 +255,10 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
 }
 
 /**
- * Derive terminal props for supported root shell and terminal-send calls.
- * Standard shell results parse their final status marker; persistent shell
- * results, background calls, errors, malformed input, or child dispatches use
- * the generic path. {@link isSettledPersistentShellCall} lets that generic
+ * Derive terminal props for supported shell and terminal-send calls, including
+ * nested Code Dispatch calls. Standard shell results parse their final status
+ * marker; persistent shell results, background calls, errors, and malformed
+ * input use the generic path. {@link isSettledPersistentShellCall} lets that generic
  * persistent result remain expandable without inventing one process status.
  * @param block - running or settled Tool block.
  * @param sessionCwd - session workspace root used to resolve workdir.
@@ -268,7 +268,6 @@ export function terminalCardModel(
   block: ToolCallBlock,
   sessionCwd?: string,
 ): TerminalCardModel | null {
-  if (block.parentCallId !== undefined) return null
   const parsed = parsedToolCall(block)
   if (parsed === null) return null
   const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args)

+ 7 - 0
packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx

@@ -157,6 +157,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
     expect(view.getByText('Bash')).toBeTruthy()
     expect(view.getByText('List notes')).toBeTruthy()
     expect(view.getByText('Tool call')).toBeTruthy()
+
+    const bashRow = nest!.querySelector('[data-sample="bash"]')!
+    expect(bashRow.getAttribute('role')).toBe('button')
+    fireEvent.click(bashRow)
+    expect(nest!.querySelector('[data-terminal]')).not.toBeNull()
+    expect(view.getByText('ls notes')).toBeTruthy()
+    expect(nest!.querySelector('[data-terminal]')!.textContent).toContain('demo.txt')
   })
 
   it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {

+ 14 - 12
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -216,10 +216,13 @@ describe('terminalCardModel', () => {
     })
   })
 
-  it('returns null without a paired call and for Code Dispatch children', () => {
+  it('returns null without a paired call', () => {
     expect(terminalCardModel(settled({ call: null }))).toBeNull()
-    expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
-    expect(terminalCardModel(running({ parentCallId: 'parent' }))).toBeNull()
+  })
+
+  it('derives the same terminal card for root calls and Code Dispatch children', () => {
+    expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toEqual(terminalCardModel(settled()))
+    expect(terminalCardModel(running({ parentCallId: 'parent' }))).toEqual(terminalCardModel(running()))
   })
 
   it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => {
@@ -632,19 +635,17 @@ describe('DetailsPanel Output section', () => {
     expect(pre?.textContent).toBe('permission denied')
   })
 
-  it('a Code Dispatch child keeps the flattened form despite valid terminal raw fields', () => {
+  it('a Code Dispatch child renders its terminal output in Details', () => {
     const child = settled({ callId: 'c1', parentCallId: 'p1' })
     const view = mount(snapshot({
       runningCalls: [running({ callId: 'p1', subCalls: [child] })],
     }), 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('输出').closest('section')
-    expect(output?.querySelector('pre')?.textContent).toContain('a.ts  b.ts')
+    expect(view.container.querySelector('[data-terminal]')?.textContent).toContain('a.ts  b.ts')
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(runStateOf(view.container)).toBe('done')
   })
 
-  it('a running Code Dispatch child keeps the running placeholder', () => {
+  it('a running Code Dispatch child renders its terminal prompt in Details', () => {
     const view = mount(snapshot({
       // The leading non-matching sub-call exercises the scan's skip.
       runningCalls: [running({
@@ -655,8 +656,9 @@ describe('DetailsPanel Output section', () => {
         ],
       })],
     }), target)
-    expect(view.getByText('运行中…')).toBeTruthy()
-    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    expect(view.queryByText('运行中…')).toBeNull()
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(runStateOf(view.container)).toBe('ongoing')
   })
 
   it('a window-truncated call head titles the panel by callId and drops the Input section', () => {

+ 8 - 2
snapshots/web/ptc-round/ui.expected.md

@@ -31,8 +31,14 @@
   - img
   - img
   - text: Code Run bash echo and catch missing file read
-- img
-- text: Bash Echo CODE_ROUND_OK Failed
+- button "Bash Echo CODE_ROUND_OK" [expanded]:
+  - img
+  - text: Bash Echo CODE_ROUND_OK
+- text: Done workspace echo CODE_ROUND_OK
+- button "Copy"
+- text: CODE_ROUND_OK
+- button "Inspect"
+- text: Failed
 - 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
   - img
   - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"