fix(docs): align ui-tool package contracts

This commit is contained in:
imccyu
2026-08-08 15:33:51 +08:00
parent 61f3982348
commit 9c10925340
7 changed files with 13 additions and 12 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
2026-07-28-web-terminal-card.md: 0493db4b86ce869ce5e699359e66dda70e526116
2026-07-28-web-terminal-card.zh.md: 10137f83a25860a42e80a7b807a8aed68e86ce18
2026-07-28-web-terminal-card.md: 026b32fed2c79533abfadc58314e2844ff556414
2026-07-28-web-terminal-card.zh.md: 4c74d880c5795cf85f32391b27e6ffd2c5074870

View File

@@ -8,11 +8,11 @@ English | [中文](2026-07-28-web-terminal-card.zh.md)
The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the former TUI rendered it as a `$`-prompt card with an exit line and a head/tail height cap.
The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `<pre>` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
The Web client ignored it. `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `<pre>` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
## Decision
`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-tool/src/client/models/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
The component's contract:
@@ -27,7 +27,7 @@ Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced
### Inline output in the chat row reverses a stated convention
`chat/ToolRow.tsx` and `contract/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
`packages/client/ui-tool/src/client/tool/components/ToolRow.tsx` and `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
The reason the reversal holds: for a shell command the output *is* the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.

View File

@@ -8,11 +8,11 @@ Status: implemented
bash 工具的调用与结果都声明 `card: 'terminal'`[渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)调用视图携带命令、一段可选的模型撰写描述以及工作目录结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot``callView`/`resultView` 上——原 TUI 曾把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `<pre>`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `<pre>`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
## Decision
`TerminalBlock``ui-primitives` 中把 shell 命令渲染为终端表面的组件bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-tool/src/client/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd因此该解析属于这道接缝两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd因此取 workspace」当分页窗口丢掉调用头时任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.``..` 段,因为 bash 执行器在运行前就已解析 workdir相对 `/w/app``..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server``share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
`TerminalBlock``ui-primitives` 中把 shell 命令渲染为终端表面的组件bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd因此该解析属于这道接缝两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd因此取 workspace」当分页窗口丢掉调用头时任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.``..` 段,因为 bash 执行器在运行前就已解析 workdir相对 `/w/app``..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server``share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
该组件的契约:
@@ -27,7 +27,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
### 聊天行内嵌输出推翻了一条既有约定
`chat/ToolRow.tsx``contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
`packages/client/ui-tool/src/client/tool/components/ToolRow.tsx``packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。

View File

@@ -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: 381253f4eddaa57b89318dd23da3a049505fdd15
README.zh.md: ae539131198771bc1d0e280bbfaa76ec0ec60792
README.md: 69d6931d33788f2df593919d160c4bfeaef8d15c
README.zh.md: eb49465a743711bd48358bf3953a485f9ef037eb

View File

@@ -32,7 +32,7 @@ This package currently owns the generic fallback and the built-in bash/pwsh, rea
## Model Experience
None. This package renders already logged Tool calls and results and does not alter model requests, Tool execution, or session events.
None, as this package renders already logged Tool calls and results without altering model requests, Tool execution, or session events.
#### KV Cache effect

View File

@@ -32,7 +32,7 @@ owner 载荷为 `ToolCallOwnerProps``callId`、`toolName`、冻结的 `block`
## 模型体验
本包只渲染已经记录的 Tool 调用和结果不改变模型请求、Tool 执行或 Session Event。
,因为本包只渲染已经记录的 Tool 调用和结果不改变模型请求、Tool 执行或 Session Event。
#### KV Cache 影响

View File

@@ -63,6 +63,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },