From 439c2066584fc916ac097bbc9d5ef139683db790 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 29 Jul 2026 11:43:23 +0800 Subject: [PATCH] fix(web): honor the terminal view's description and resolved workdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, each verified against the presentation contract: The call view's `description` was dropped, so a presenter that authors one (`terminal_send` declares `Terminal `) lost the contract's above-card text and the row fell back to an unrelated args-derived summary. It now rides the same derivation and outranks that summary. A relative workdir was concatenated but never normalized, while the bash executor resolves it before running: with session cwd `/w/app` and workdir `..` the command runs in `/w`, yet the card displayed the label `..`. The resolved path now collapses `.`/`..` segments, drops a `..` that would climb past a root the way a filesystem does, and keeps a Windows path's separators since the value is only ever displayed. `run_code` sub-dispatches carry no presenter views on the shipped wire — `session.ts` folds `tool/code-dispatch(-start)` with null views and the host's `viewFor` presents only top-level call/result events — so a nested bash call cannot reach a terminal card. The existing test only passed by injecting views that path cannot produce; it now says so, and a second arm pins the no-view shape the wire actually delivers. Restoring master's fixture also fixed the todo snapshot lane, which my earlier merge had broken by dropping the projection support the todo dock reads. The terminal sample turn moved ahead of the todo turn, because the standing plan retires at the next `turn/start` and a turn appended after it emptied the dock. The card props are now nested under `card` so a render site spreads exactly the primitive's own surface, and the fixture reads each sample's authored exit status instead of re-implementing the bash tool's `parseExitStatus`. --- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 6 +- .../2026-07-28-web-terminal-card.zh.md | 6 +- apps/web/tests/terminal-card.snapshot.ts | 12 +- .../client/connection/src/client/fixture.ts | 55 ++++---- .../src/client/chat/ToolRow.tsx | 2 +- .../client/contract/terminal-card-model.ts | 92 ++++++++++---- .../src/client/skeleton/DetailsPanel.tsx | 2 +- .../src/client/toolviews/bash-sample.tsx | 6 +- .../tests/terminal-card.spec.tsx | 118 +++++++++++++++--- 10 files changed, 226 insertions(+), 77 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 601de022d5..022c4f84ed 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 78da1e783189968b5b20c17d6a84764d5a5788cb -2026-07-28-web-terminal-card.zh.md: 462662fa555777ffdb71fcc24dd306935123faf3 +2026-07-28-web-terminal-card.md: 8e4701b49373af3495c6f424c4ae2777f5bfcb75 +2026-07-28-web-terminal-card.zh.md: 07341ca942864281390f3820db88388fd61cef0e diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 78da1e7831..8e4701b493 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -12,7 +12,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ ## Decision -`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. 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. +`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. 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. 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 `..`. 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. The component's contract: @@ -49,6 +49,8 @@ One premise of that split has since weakened: [tool rows stopped being details-p `TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. +A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the code-dispatch wire is that seam's own change. + Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own. ## Testing @@ -57,7 +59,7 @@ Inline rendering is licensed for the terminal intent alone. A future intent that `packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. -`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`); that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker. +`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker. `apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet) and starts to the left of the card surface itself, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `terminal-card.expected.md` golden records the resolved workspace in the prompt row, which is what a bash call with no `workdir` must show instead of a bare `$`. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 462662fa55..07341ca942 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -12,7 +12,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c ## Decision -`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。 +`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。 该组件的契约: @@ -49,6 +49,8 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c `TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。 +在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于那道接缝自身的改动。 + 内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。 ## Testing @@ -57,7 +59,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c `packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 -`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`);该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。 +`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。 `apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且其起点位于卡片表面本身的左侧;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `terminal-card.expected.md` 基准记录了提示行中已解析的 workspace——这正是不带 `workdir` 的 bash 调用应当显示的内容,而非一个裸 `$`。 diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts index 55e74d75fa..0154a3d7fd 100644 --- a/apps/web/tests/terminal-card.snapshot.ts +++ b/apps/web/tests/terminal-card.snapshot.ts @@ -4,7 +4,7 @@ // Opens the fixture history session and pins the `card: 'terminal'` render // intent at both of its conversation render sites, for both chat-row shapes: // turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and -// turn 66's `bash` on the keyed BashRow registration (resident body). Turn 66 +// 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. @@ -167,11 +167,15 @@ async function openFixtureSession(): Promise { }, { timeout: 10_000 }) } -/** The keyed BashRow of fixture turn 66 (the one carrying the ANSI sample). */ +/** 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"]')] - .find(node => visibleText(node).includes('pnpm run check')) - if (row === undefined) throw new Error('keyed bash row for turn 66 missing') + .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 } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 2056eb52ed..84a884087a 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -93,7 +93,7 @@ function sgr(code: number, body: string): string { /** * Terminal output sample for fixture turn 66, authored to carry every feature - * the terminal card draws that turn 60's three plain lines cannot reach: + * 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 @@ -125,6 +125,15 @@ const TERMINAL_OUTPUT_FIXTURE = [ '[exit code: 1]', ].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 = { + [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 }, +} + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -200,8 +209,7 @@ function buildAlphaLog(): SessionEvent[] { } // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above - // stays presenter-less as the unknown fallback. Turn 66 is the second terminal sample, carrying - // what turn 60's three plain lines cannot (see TERMINAL_OUTPUT_FIXTURE) through the keyed row. + // stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) @@ -272,21 +280,26 @@ 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 recovered from + // the trailing marker the bash tool appends. Named `bash`, so it also covers + // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback + // row) — the two chat-row shapes the terminal card renders in. + // + // 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). const callIndex = events.length - 4 const callTime = events[callIndex]?.time as number events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) - // Turn 66: the terminal sample turn 60's three clean lines cannot cover — - // ANSI SGR coloring, output past the terminal card's height cap, a nested - // cwd whose prompt label is its last segment, and a non-zero exit recovered - // from the trailing marker the bash tool appends. Named `bash`, so it also - // covers the keyed toolview row (turn 60's `fx-bash` covers the render-site - // fallback row) — the two chat-row shapes the terminal card renders in. - toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } @@ -306,8 +319,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { } switch (name) { // Both names present the same terminal card: `fx-bash` lands on the - // render-site fallback row, `bash` on the keyed BashRow registration, so - // the two chat-row shapes of one render intent are both reachable. + // 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 终端样本' } @@ -325,24 +337,15 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { } } -/** - * Recover the exit status from a trailing `[exit code: N]` marker, mirroring - * the real bash tool's `parseExitStatus` (this package must not depend on a - * tool package, so the marker contract is re-read rather than imported). - * @param text - the rendered result text. - * @returns the recovered exit code (0 when the marker is absent). - */ -function fixtureExitCode(text: string): number { - const marker = /\n\[exit code: (\d+)\]$/.exec(text) - return marker?.[1] === undefined ? 0 : Number(marker[1]) -} - function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { const call = presentCall(name, argsRaw) if (call === undefined) return undefined switch (call.card) { case 'terminal': - return { card: 'terminal', output: resultText, exitCode: fixtureExitCode(resultText) } + // 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': diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 9d6488ef97..30ef595397 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -157,7 +157,7 @@ export function ToolRow({ )} {open && (terminalBody !== null - ? + ? : variant === 'code' ? :
{text}
)} diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index bf832e2961..a1439c6bc1 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -28,10 +28,21 @@ export const CHAT_TERMINAL_MAX_LINES = 8 * client has no home path for the session host (a cwd renders as its last * path segment), and `maxLines`/`className` belong to each render site. */ -export type TerminalCardModel = Pick< - TerminalBlockProps, - 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running' -> +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 + /** + * 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 @@ -47,8 +58,41 @@ export type TerminalCardModel = Pick< */ function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined { if (viewCwd === undefined || viewCwd === '') return sessionCwd - if (sessionCwd === undefined || sessionCwd === '') return viewCwd - return resolveToolPath(sessionCwd, viewCwd) + 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. + * @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 + const backslashed = path.includes('\\') && !path.includes('/') + const separator = backslashed ? '\\' : '/' + const leading = /^[/\\]/.test(path) ? separator : '' + const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? '' + const kept: string[] = [] + for (const segment of path.slice(drive.length).split(/[/\\]/)) { + if (segment === '' || segment === '.') continue + if (segment === '..') { + // Nothing to climb from: at a root the segment is dropped, matching the + // filesystem; on a relative path the `..` has to stay, since it is still + // meaningful against a cwd this function cannot see. + if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop() + else if (leading === '' && drive === '') kept.push(segment) + continue + } + kept.push(segment) + } + const body = kept.join(separator) + return drive === '' ? `${leading}${body}` : `${drive}${leading === '' ? separator : leading}${body}` } /** @@ -82,25 +126,31 @@ export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): Te if (!('kind' in block)) { // Running: the call view exists, the result view does not yet. return call === null ? null : { - command: call.title, - cwd: resolveTerminalCwd(call.cwd, sessionCwd), - output: undefined, - exitCode: undefined, - signal: undefined, - running: true, + 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 { - // 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 ?? '', - cwd: resolveTerminalCwd(call?.cwd, sessionCwd), - output: result.output, - exitCode: result.exitCode, - signal: result.signal, - running: false, + 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 ?? '', + cwd: resolveTerminalCwd(call?.cwd, sessionCwd), + output: result.output, + exitCode: result.exitCode, + signal: result.signal, + running: false, + }, } } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index a7add10328..630334eae7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -135,7 +135,7 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo */ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) { const terminal = terminalCardModel(material.block, cwd) - if (terminal !== null) return + if (terminal !== null) return // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 6800e79180..1e6e9183fc 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -64,10 +64,12 @@ export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProp {isChild && scoped} {model.title} - {model.summary} + {/* The terminal presenter's description is the contractual + above-card summary; it outranks the args-derived one. */} + {terminal?.description ?? model.summary} {terminal !== null && ( - + )} ) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 0b3ba5e849..7df9de7f36 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -63,8 +63,11 @@ const settled = (over?: Partial): ToolResultNode => ({ describe('terminalCardModel', () => { it('derives a running card from the call view alone', () => { expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({ - command: 'ls -la', cwd: '/projects/app', output: undefined, - exitCode: undefined, signal: undefined, running: true, + description: 'List files', + card: { + command: 'ls -la', cwd: '/projects/app', output: undefined, + exitCode: undefined, signal: undefined, running: true, + }, }) }) @@ -73,12 +76,15 @@ describe('terminalCardModel', () => { callView: callTerminal({ cwd: '/projects/app' }), resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }), }))).toEqual({ - command: 'ls -la', cwd: '/projects/app', output: 'boom\n', - exitCode: 2, signal: undefined, running: false, + 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' }, - }))?.signal).toBe('SIGTERM') + }))?.card.signal).toBe('SIGTERM') }) it('takes the result view\'s replacement title over the pending one', () => { @@ -87,30 +93,75 @@ describe('terminalCardModel', () => { expect(terminalCardModel(settled({ callView: callTerminal({ title: 'pnpm run check' }), resultView: resultTerminal({ title: 'pnpm run check --filter web' }), - }))?.command).toBe('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())?.command).toBe('ls -la') + 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')?.cwd).toBe('/w/app') + 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')?.cwd).toBe('/w/app/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')?.cwd).toBe('/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' }), - }))?.cwd).toBe('packages/ui') - expect(terminalCardModel(settled())?.cwd).toBeUndefined() + }))?.card.cwd).toBe('packages/ui') + expect(terminalCardModel(settled())?.card.cwd).toBeUndefined() // The running arm resolves identically. - expect(terminalCardModel(running(), '/w/app')?.cwd).toBe('/w/app') + 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('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', () => { @@ -118,8 +169,8 @@ describe('terminalCardModel', () => { const truncated = { call: null, callView: null } expect(terminalCardModel(settled({ ...truncated, resultView: resultTerminal({ title: 'ls -la' }), - }))).toMatchObject({ command: 'ls -la', cwd: undefined, running: false }) - expect(terminalCardModel(settled(truncated))).toMatchObject({ command: '', cwd: undefined }) + }))?.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', () => { @@ -244,6 +295,23 @@ describe('BashRow terminal card', () => { 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() + 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() + expect(view.getByText('List files')).toBeTruthy() + }) + it('a non-terminal bash call (background start) renders the summary row alone', () => { const view = render( { expect(pre?.textContent).toBe('permission denied') }) - it('a run_code sub-dispatch resolves to its own terminal card', () => { + // 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
 (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.