From 71adea8ba492f30835851a28b5fb02758a541894 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:42:59 +0800 Subject: [PATCH 1/9] feat(web): render grep/glob search output as a search card Consume the card:'search' result view (matches grouped by file for grep, a path list for glob) the search backend PR added. SearchBlock (ui-primitives) draws both kinds via the kind discriminant with a per-file collapse, a truncation pill, a height cap matching TerminalBlock, and a copy control; search-card-model is the single resultView derivation; a keyed SearchRow registers under grep and glob with the card resident under its summary. The generic fallback and the details panel are search-aware. Fixture gains grep and glob turns for the built-boot snapshot. --- .../2026-07-30-web-search-card.i18n.yaml | 6 + .../feature/2026-07-30-web-search-card.md | 64 ++++ .../feature/2026-07-30-web-search-card.zh.md | 64 ++++ .../client/connection/src/client/fixture.ts | 89 +++++- .../ui-conversation/src/client/apply.ts | 5 + .../src/client/chat/GenericToolCard.tsx | 8 +- .../src/client/chat/ToolRow.module.css | 15 +- .../src/client/chat/ToolRow.tsx | 40 ++- .../src/client/contract/search-card-model.ts | 85 ++++++ .../src/client/skeleton/DetailsPanel.tsx | 11 +- .../client/toolviews/search-sample.module.css | 95 ++++++ .../src/client/toolviews/search-sample.tsx | 91 ++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 9 +- .../tests/search-card.spec.tsx | 276 ++++++++++++++++++ .../ui-primitives/src/SearchBlock.module.css | 125 ++++++++ .../client/ui-primitives/src/SearchBlock.tsx | 263 +++++++++++++++++ packages/client/ui-primitives/src/index.ts | 4 + .../ui-primitives/tests/search-block.spec.tsx | 196 +++++++++++++ 18 files changed, 1415 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-search-card.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md create mode 100644 packages/client/ui-conversation/src/client/contract/search-card-model.ts create mode 100644 packages/client/ui-conversation/src/client/toolviews/search-sample.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/search-sample.tsx create mode 100644 packages/client/ui-conversation/tests/search-card.spec.tsx create mode 100644 packages/client/ui-primitives/src/SearchBlock.module.css create mode 100644 packages/client/ui-primitives/src/SearchBlock.tsx create mode 100644 packages/client/ui-primitives/tests/search-block.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml new file mode 100644 index 0000000000..a0b66d0f87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md +2026-07-30-web-search-card.md: 38d8b2f10b5b5b4f9b1d5c43a726877159737440 +2026-07-30-web-search-card.zh.md: 1d1f371d2fa846219ea8cc434727b5354508b454 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md new file mode 100644 index 0000000000..38d8b2f10b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -0,0 +1,64 @@ +# Agent Note: Web search card — the grep and glob render intent reaches the browser + +Status: implemented + +English | [中文](2026-07-30-web-search-card.zh.md) + +## Problem + +The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`kind: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`kind: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text. + +This is the follow-up the search render card note names: that PR was the backend contract and its two producers; this PR is the web consumer. + +## Decision + +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, and a `card` value this client version does not know. + +The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. + +One component draws both shapes, discriminated by `kind`, because `grep` and `glob` are the same visual object — a search result. `SearchMatchesBlockProps` (`kind: 'matches'`) and `SearchPathsBlockProps` (`kind: 'paths'`) keep each shape's fields required rather than a single interface with everything optional. The component flattens whichever shape it holds into one list of render rows — a file header row plus its match rows for the matches shape, one path row per path for the paths shape — so the height cap counts a file header as one row exactly as a match line or a path, and the head/tail slice arithmetic is `TerminalBlock`'s (`ceil(max/2)` head, the remainder tail), so a long search result and a long command output cut at the same place across the two cards. + +The component's contract: + +- **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text. +- **Flat path list.** The paths shape renders one path per row, no headers. +- **A capped indicator.** When `truncated`, a pill reads `已截断 · 共 {total}` beside the banner summary, so the card never presents a capped page as the complete result — a reader who wants the rest follows the spill locator in the model-facing text, exactly as the model does. The banner summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). +- **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding. +- **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`. +- **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing. + +Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search card reads as one family with them; `white-space: pre` plus horizontal scroll is the shared deliberate divergence. + +### Render sites + +Three sites consume the derivation, mirroring the terminal card's placement exactly: + +- **The keyed `SearchRow`** (`toolviews/search-sample.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) +- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card behind the row's expand toggle. +- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, keeping the JSON Input section. + +`CHAT_SEARCH_MAX_LINES` (8) is the row cap, half the primitive's default the panel keeps, for the same reason as `CHAT_TERMINAL_MAX_LINES`: the chat flow is a summary surface read across many calls, the panel is the single-call reading surface. + +## Alternatives considered + +**Two card components, one per tool.** Rejected: `grep` and `glob` are the same visual object discriminated only by `kind`, so two components would duplicate the banner, the height cap, the copy control, and the no-wrap geometry. One component switching on `kind` is what the backend's single `card: 'search'` view is for. + +**A `SearchCallView` so the row renders a card while the search runs.** Rejected: the backend contract deliberately has no call-time search view — a search has no matches or paths before `execute`. The running row shows its summary alone, and `searchCardModel` returns null for a running block, which is faithful to what exists. + +**Reuse `TerminalBlock` or `CodeBlock`.** Rejected: neither models per-file collapsible groups or a truncation pill, and both would need the grouped-matches shape bolted on. The three blocks share their geometry and font tokens instead, which is the only part where one implementation is correct for all. + +## Consequences + +`SearchBlock` reads only the search 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 search capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. Extending `ToolRow` with a `search` body prop adds one arm beside `terminal`; a call carries at most one card kind, so the two are never both present on a row. + +## Testing + +`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the truncation pill with its pre-cap total, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. + +`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, and each null arm (running, no views, generic, terminal, unknown card); the chat row's expand-gated matches and paths bodies through `GenericToolCard` against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` and a `glob` turn emitting `kind: 'paths'` as `resultView`, both truncated, driving the built-boot snapshot and the live `?fixture` server. + +## Related + +- [Search render intent — grep and glob emit a structured search card](2026-07-30-search-render-card.md) — the backend contract and its two producers; this is its named web-consumer follow-up. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a tool's render intent reaches the browser through a `ui-primitives` block, a single `contract/*-card-model.ts` derivation, and the same three render sites. +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary both cards consume. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md new file mode 100644 index 0000000000..1d1f371d2f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -0,0 +1,64 @@ +# Agent Note:Web 搜索卡片 —— grep 与 glob 的 render intent 到达浏览器 + +Status: implemented + +[English](2026-07-30-web-search-card.md) | 中文 + +## Problem + +`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`kind: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`kind: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。 + +这正是 search render card note 指名的后续:那个 PR 是后端契约和它的两个生产者,本 PR 是 web 消费者。 + +## Decision + +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图,以及本客户端版本不认识的 `card` 值。 + +与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 + +一个组件绘制两种形态,用 `kind` 区分,因为 `grep` 和 `glob` 是同一个视觉对象 —— 一个搜索结果。`SearchMatchesBlockProps`(`kind: 'matches'`)和 `SearchPathsBlockProps`(`kind: 'paths'`)让每种形态的字段保持必填,而不是所有字段都可选的单一接口。组件把它持有的形态压平成一个渲染行列表 —— matches 形态是一个文件头行加它的匹配行,paths 形态是每个路径一行 —— 于是高度上限把一个文件头当作一行来计,与一条匹配行或一个路径相同,头/尾切片算术就是 `TerminalBlock` 的(`ceil(max/2)` 头,其余为尾),因此一个长搜索结果和一段长命令输出在两张卡片间在同一处截断。 + +组件契约: + +- **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 +- **扁平路径列表。** paths 形态每行一个路径,无头行。 +- **截断指示。** `truncated` 时,横幅摘要旁一个 pill 显示 `已截断 · 共 {total}`,因此卡片绝不把一个被截断的页面呈现为完整结果 —— 想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。横幅摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 +- **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 +- **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 + +几何、圆角、字体镜像 `CodeBlock` 与 `TerminalBlock`,因此搜索卡片与它们读作同一族;`white-space: pre` 加横向滚动是它们共享的刻意分歧。 + +### 渲染点 + +三个渲染点消费该推导,与终端卡片的落位完全一致: + +- **keyed `SearchRow`**(`toolviews/search-sample.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) +- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片。 +- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,保留 JSON Input 段。 + +`CHAT_SEARCH_MAX_LINES`(8)是行内上限,为 primitive 默认值的一半(panel 保留默认值),理由与 `CHAT_TERMINAL_MAX_LINES` 相同:chat 流是跨多次调用扫读的摘要表面,panel 是单次调用的阅读表面。 + +## Alternatives considered + +**两个卡片组件,每个工具一个。** 否决:`grep` 与 `glob` 是仅由 `kind` 区分的同一视觉对象,两个组件会重复横幅、高度上限、复制控件与不换行几何。一个按 `kind` 分支的组件正是后端那个单一 `card: 'search'` 视图的用途。 + +**加一个 `SearchCallView`,让行在搜索运行时就渲染卡片。** 否决:后端契约刻意没有调用阶段的搜索视图 —— 搜索在 `execute` 前没有匹配或路径。运行中的行只显示摘要,`searchCardModel` 对运行块返回 null,忠实于实际存在的东西。 + +**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或截断 pill,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。 + +## Consequences + +`SearchBlock` 只读搜索视图的字段,因此保持为 render intent 所携内容的纯函数 —— 无会话查询,与产生该视图的 presenter 一样可重放。没有搜索能力的 UI 仍得到 bridge 的围栏回退;工具的结果形态没有任何改变。给 `ToolRow` 扩一个 `search` body prop 只在 `terminal` 旁加一个分支;一次调用至多携带一种卡片,因此两者绝不同时出现在一行。 + +## Testing + +`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、带 pre-cap total 的截断 pill、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 + +`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片);通过 `GenericToolCard` 的展开门控 matches 与 paths body,对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind,对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn 与一个发出 `kind: 'paths'` 的 `glob` turn 作为 `resultView`,两者都截断,驱动 built-boot snapshot 与实时 `?fixture` 服务。 + +## Related + +- [Search render intent —— grep 与 glob 发出结构化搜索卡片](2026-07-30-search-render-card.md) —— 后端契约与它的两个生产者;本 note 是它指名的 web 消费者后续。 +- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本 note 镜像的先例:工具的 render intent 通过一个 `ui-primitives` 块、一个 `contract/*-card-model.ts` 推导、以及同样的三个渲染点到达浏览器。 +- [工具调用呈现的标签化 render-intent 联合](../architecture/2026-07-02-tool-render-intent-union.md) —— 两张卡片都消费的 `card` 标签词汇。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..6941be9dff 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -136,6 +136,60 @@ const TERMINAL_EXIT_STATUS: Record>(() => new Set())' }, + ], + }, + { + path: 'packages/client/ui-conversation/src/client/contract/search-card-model.ts', + matches: [ + { lineNumber: 24, line: 'export const CHAT_SEARCH_MAX_LINES = 8' }, + { lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' }, + ], + }, +] + +/** + * The model-facing grep render text for the sample, grouped under file headers + * with `Line N:` rows and a spill footer — what a UI without a search card + * shows, attached as the view's `content`. + */ +const SEARCH_MATCHES_TEXT = [ + ...SEARCH_MATCHES_FIXTURE.flatMap(file => [ + file.path, + ...file.matches.map(m => ` Line ${m.lineNumber}: ${m.line}`), + ]), + '', + '(已显示 5 处匹配中的前 5 处,共 42 处;其余见溢出文件)', +].join('\n') + +/** + * Structured glob result for the search sample (turn 68): a flat path list, + * truncated with a larger `total` so the path card shows its capped indicator. + */ +const SEARCH_PATHS_FIXTURE = [ + 'packages/client/ui-primitives/src/SearchBlock.tsx', + 'packages/client/ui-primitives/src/SearchBlock.module.css', + 'packages/client/ui-conversation/src/client/contract/search-card-model.ts', + 'packages/client/ui-conversation/src/client/toolviews/search-sample.tsx', + 'packages/client/ui-conversation/src/client/toolviews/search-sample.module.css', +] + +/** The model-facing glob render text: the newline-joined path list plus a spill footer. */ +const SEARCH_PATHS_TEXT = [...SEARCH_PATHS_FIXTURE, '', '(共 23 个路径,已显示前 5 个)'].join('\n') + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -296,8 +350,18 @@ function buildAlphaLog(): SessionEvent[] { // 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) + // Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'` + // `kind: 'matches'` result view (grouped-by-file matches, truncated with a + // larger `total`), `glob` emits `kind: 'paths'` (a flat path list, likewise + // truncated). Both ride the keyed SearchRow registration under their own + // names; the render-site fallback row is covered by the model derivation + // tests, since every fixture search tool has a keyed row. Ordered before the + // todo turn for the same standing-plan reason the bash turn is. + toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) + toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(68, '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). @@ -336,6 +400,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } case 'write': return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } + // A search call stays a generic card (kind: 'search'): the structured + // matches/paths exist only after execute, so the search card is result-time + // only (presentResult builds it). This mirrors the real grep/glob presenters. + case 'grep': + return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args } + case 'glob': + return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } @@ -344,6 +415,22 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { const call = presentCall(name, argsRaw) if (call === undefined) return undefined + // Search is result-time only: the call stays a generic search card, and the + // result view carries the structured shape the card renders, with the + // model-facing text as `content` for a UI without a search card. `total` + // exceeds the retained count so the card shows its capped indicator. + if (name === 'grep') { + return { + card: 'search', kind: 'matches', files: SEARCH_MATCHES_FIXTURE, + truncated: true, total: 42, content: text(resultText), + } + } + if (name === 'glob') { + return { + card: 'search', kind: 'paths', paths: SEARCH_PATHS_FIXTURE, + truncated: true, total: 23, content: text(resultText), + } + } switch (call.card) { case 'terminal': // The sample's own exit status, authored beside it: re-parsing the diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7f3aeb38cc..7e714fd8d8 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { searchToolview } from './toolviews/search-sample.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' @@ -254,6 +255,10 @@ export function apply(ctx: Context): void { // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The grep/glob search row rides the same seam: one component registered + // under both tool names, since both declare the same search render intent. + ctx.plugin(searchToolview) + // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index ce55d84f57..8bfb700218 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -10,6 +10,7 @@ import { IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowOwnerProps } from '../contract/slots.ts' +import { searchCardModel } from '../contract/search-card-model.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' @@ -29,6 +30,7 @@ const VARIANT_ICONS: Record = { export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) + const search = searchCardModel(block) const singleFile = model.filePath !== undefined return ( - : variant === 'code' - ? - :
{text}
)} + : searchBody !== null + ? + : variant === 'code' + ? + :
{text}
)} ) } diff --git a/packages/client/ui-conversation/src/client/contract/search-card-model.ts b/packages/client/ui-conversation/src/client/contract/search-card-model.ts new file mode 100644 index 0000000000..8373dc2ddd --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/search-card-model.ts @@ -0,0 +1,85 @@ +/** + * Pure derivation of the search-card props from a frozen call slice: the + * `card:'search'` render intent the `grep` and `glob` tools declare arrives on + * the snapshot as `resultView`, and this is the one place that turns it into + * what {@link SearchBlock} draws. Both conversation render sites (the chat tool + * row's resident body and the details panel's Output section) call this, so the + * grouped matches or the path list they show are derived once. + * + * The search card is result-time only: a search call has no matches or paths + * before `execute`, so its pending state stays a `GenericCallView` + * ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation + * therefore reads only `resultView` and returns null for a still-running call, + * unlike the terminal card whose call view carries the command before + * execution. + * @module + */ +import type { SearchBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Distributive `Omit`: a plain `Omit` keeps only the keys common to + * both members, which would drop the `files`/`paths` discriminated fields. + * Distributing over the naked type parameter `T` preserves each shape. + */ +type DistributiveOmit = T extends unknown ? Omit : never + +/** The {@link SearchBlockProps} union minus each render site's own fields. */ +type SearchBlockModelProps = DistributiveOmit + +/** + * Result rows the chat row's resident search body shows before collapsing the + * middle — half the primitive's own default, which the details panel keeps. A + * chat row is a summary surface inside the message flow: the flow must stay + * scannable across many calls, while the details panel is the single-call + * reading surface. A design constant of this UI's row geometry, not a + * deployment choice, so it is fixed here rather than a plugin Config field. + */ +export const CHAT_SEARCH_MAX_LINES = 8 + +/** + * The {@link SearchBlock} props this derivation owns. Held as a nested object + * (`card`) so a render site spreads exactly the primitive's own surface and can + * never leak a neighbouring field into it. `maxLines`/`className` belong to each + * render site. + */ +export interface SearchCardModel { + /** + * The props {@link SearchBlock} draws, minus each render site's own + * `maxLines`/`className`. + */ + card: SearchBlockModelProps + /** + * The result view's replacement title, which the presentation contract lets a + * search tool set at settle time. Absent when the presenter supplied none; a + * row then keeps its args-derived summary. + */ + title: string | undefined +} + +/** + * Derive the search-card props for a tool call, or null when this call is not a + * search card and belongs on the generic path. + * + * Only the result side matters: the search card carries no call-time state, so + * a still-running call (no result view) is null, as is a settled call whose + * result view is not a search card — including a `card` value this UI version + * does not know, which arrives over the wire and cannot be trusted to be one of + * the compiled variants, and a generic result a `grep`/`glob` failure or nested + * `run_code` dispatch produces (its text keeps the generic path). + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the search-card props, or null for the generic path. + */ +export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { + // Running: no result view exists yet, and a search card is result-only. + if (!('kind' in block)) return null + const result = block.resultView?.card === 'search' ? block.resultView : null + if (result === null) return null + const common = { truncated: result.truncated, total: result.total } + return { + title: result.title, + card: result.kind === 'matches' + ? { kind: 'matches', files: result.files, ...common } + : { kind: 'paths', paths: result.paths, ...common }, + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..e164d955b9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,10 +7,11 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, SearchBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { searchCardModel } from '../contract/search-card-model.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A search-card call — + * a `grep`/`glob` result view — renders through the shared SearchBlock at the + * same full height allowance. Every other call, and a running call with no card + * yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -147,6 +150,8 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const search = searchCardModel(material.block) + if (search !== 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/search-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/search-sample.module.css new file mode 100644 index 0000000000..5c4eb1f7db --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/search-sample.module.css @@ -0,0 +1,95 @@ +/* Search toolview: same geometry/tokens as ToolRow and BashRow (figma + Search · summary), plus the search card the row stacks resident under its + summary line. */ + +/* Summary line over the search card; the summary row keeps its own 24px + height, so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.search { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow / BashRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-search-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-search-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx new file mode 100644 index 0000000000..d717132d72 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx @@ -0,0 +1,91 @@ +// Search toolview registrant: the keyed toolview hole (ctx.slots.register + +// ToolRowProps only — never imports the chat domain). One SearchRow component +// registered under both `grep` and `glob`, since both tools declare the same +// `card: 'search'` render intent and render as one visual object; the row reads +// the `kind` discriminant off the derived model to draw grouped matches or a +// path list. Product chrome matches ToolRow / BashRow (Search · {summary}). +// +// A search call declares its render intent result-time only, so this row's +// search card is resident below the summary rather than expand-gated: the row +// itself has no expand control, and the card's own copy, per-file collapse, and +// head/tail expand are the row's only interactions. CHAT_SEARCH_MAX_LINES is +// passed as `maxLines` — the chat flow's tighter cap over the block's own +// default of 16 — so a large result stays bounded in the message flow. + +import type { Context } from 'cordis' +import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './search-sample.module.css' + +/** Leading-slot glyph substitution: the search icon yields to the terminal + * state semantic (error = red, interrupted = amber). Running keeps the icon — + * the row sweep carries the in-flight signal. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; assistive technology needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the + * completed search's card resident below it. The summary row is not a + * details-panel control, so the card's copy, per-file collapse, and expand + * controls are the row's only interactions. Registered under both `grep` and + * `glob`; the derived model's `kind` decides the card shape. + */ +export function SearchRow({ toolName, block }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const search = searchCardModel(block) + const status = stateStatus(model.state) + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {/* The result view's replacement title outranks the args-derived + summary, matching the terminal card's description precedence. */} + {search?.title ?? model.summary} +
+ {search !== null && ( + + )} +
+ ) +} + +/** + * The search toolview as a plain registrant plugin. `inject` carries the + * load-order seam: requiring the conversation service guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is registered. + * The one component registers under both keys, since `grep` and `glob` are the + * same visual object discriminated only by the result view's `kind`. + */ +export const searchToolview = { + name: 'search-toolview', + inject: ['slots', 'conversation'], + /** + * Register the search row into the chat view's keyed toolview hole under both + * the `grep` and `glob` tool names. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob' }, SearchRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..261e87890e 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the search row (grep + glob), and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // All registrant plugins' inject: ['slots', 'conversation'] resolved — the + // service being present implies the chat entry declared the hole first. The + // one search row registers under both grep and glob. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx new file mode 100644 index 0000000000..c728b5b1a3 --- /dev/null +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +// The search render intent on the web side: the pure searchCardModel derivation +// over resultView, and the conversation render sites that consume it — the chat +// tool row (GenericToolCard's expand-gated body and SearchRow's resident card) +// and the details panel's Output section. The keyed registration under both grep +// and glob is pinned here too. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { SearchRow, searchToolview } from '../src/client/toolviews/search-sample.tsx' + +afterEach(cleanup) + +/** The rendered search card's kind attribute, so a render site cannot silently drop it. */ +function searchKindOf(container: HTMLElement): string | null { + return container.querySelector('[data-search]')?.getAttribute('data-search') ?? null +} + +/** The rendered result rows of the search card, one string per visible row. */ +function searchRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[data-search] [class^="_line_"]')].map(row => row.textContent ?? '') +} + +const SID = 's1' as SessionId + +const GREP_ARGS = '{"pattern":"foo","path":"src"}' +const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}' + +/** A grep result view: matches grouped by file. */ +const resultMatches = (over?: Partial>): ToolResultView => ({ + card: 'search', kind: 'matches', + files: [ + { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, + { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, + ], + truncated: false, total: 3, ...over, +}) + +/** A glob result view: a flat path list. */ +const resultPaths = (over?: Partial>): ToolResultView => ({ + card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, +}) + +const runningGrep = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over, +}) + +const settledGrep = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'grep', argsRaw: GREP_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false, + callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over, +}) + +const settledGlob = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', + call: { name: 'glob', argsRaw: GLOB_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false, + callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over, +}) + +describe('searchCardModel', () => { + it('derives a matches card from the grep result view', () => { + expect(searchCardModel(settledGrep())).toEqual({ + title: undefined, + card: { + kind: 'matches', + files: [ + { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, + { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, + ], + truncated: false, total: 3, + }, + }) + }) + + it('derives a paths card from the glob result view, carrying the truncation signal', () => { + expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ + title: undefined, + card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, + }) + }) + + it('carries the result view\'s replacement title when the presenter sets one', () => { + expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches') + // Without one it is absent, so the row keeps its args-derived summary. + expect(searchCardModel(settledGrep())?.title).toBeUndefined() + }) + + it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => { + // A search card is result-time only: a running call has no result view yet. + expect(searchCardModel(runningGrep())).toBeNull() + expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull() + // A generic result settles a search call as a generic card (grep/glob failure + // or a nested run_code dispatch), which keeps the generic path. + expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull() + // A terminal result view is a different card entirely. + expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart' } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() + }) +}) + +describe('chat row search body (GenericToolCard fallback)', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), + }) + + it('the expanded body is the grouped matches, capped tighter than the panel', () => { + expect(CHAT_SEARCH_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the one-line summary row only, no card. + expect(view.queryByText(/const foo = 1/)).toBeNull() + fireEvent.click(view.container.querySelector('button')!) + expect(searchRows(view.container)).toContain('12: const foo = 1') + expect(view.getByText('a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('matches') + // The args JSON body the generic path would have shown is gone. + expect(view.queryByText(/"pattern"/)).toBeNull() + }) + + it('the glob fallback expands to the flat path card', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('src/a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('paths') + }) + + it('a non-search result keeps the args-JSON text body', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText(/"pattern"/)).toBeTruthy() + expect(searchKindOf(view.container)).toBeNull() + }) +}) + +describe('SearchRow keyed card', () => { + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, + } as unknown as ToolRowProps) + + it('renders the grep card resident under the summary row, without an expand gesture', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(searchRows(view.container)).toContain('12: const foo = 1') + expect(searchKindOf(view.container)).toBe('matches') + // The card's controls are the row's only interactions. + expect(view.getByText('复制')).toBeTruthy() + }) + + it('renders the glob path card resident', () => { + const view = render() + expect(view.getByText('src/a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('paths') + }) + + it('agrees with the summary row about the run state', () => { + const runningView = render() + expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running') + // No result view yet, so no resident card. + expect(searchKindOf(runningView.container)).toBeNull() + cleanup() + const errorView = render() + expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') + }) + + it('shows the result view\'s replacement title instead of the args summary', () => { + const view = render() + expect(view.getByText('3 matches in 2 files')).toBeTruthy() + }) + + it('keeps the args-derived summary when the result view has no title', () => { + const view = render() + expect(view.getByText('foo')).toBeTruthy() + }) + + it('registers the one row component under both grep and glob keys', () => { + const registered: { key: unknown; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + }, + }, + } as never + searchToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['grep', 'glob']) + // One component, two keys. + expect(registered[0]!.component).toBe(SearchRow) + expect(registered[1]!.component).toBe(SearchRow) + expect(searchToolview.inject).toEqual(['slots', 'conversation']) + }) +}) + +describe('DetailsPanel Output section (search)', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' } + const globTarget: SelectionTarget = { turnSeq: 11, callId: 'c2', toolName: 'glob' } + + it('renders the grep matches card at full height, keeping the JSON Input section', () => { + const view = mount(snapshot({ nodes: [settledGrep()] }), grepTarget) + expect(view.getByText(/"pattern"/)).toBeTruthy() + expect(searchRows(view.container)).toContain('12: const foo = 1') + expect(searchKindOf(view.container)).toBe('matches') + }) + + it('renders the glob path card', () => { + const view = mount(snapshot({ nodes: [settledGlob()] }), globTarget) + expect(view.getByText('src/a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('paths') + }) + + it('a non-search result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledGrep({ callView: null, resultView: null })], + }), grepTarget) + expect(searchKindOf(view.container)).toBeNull() + const output = view.getByText('Output').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1') + }) +}) diff --git a/packages/client/ui-primitives/src/SearchBlock.module.css b/packages/client/ui-primitives/src/SearchBlock.module.css new file mode 100644 index 0000000000..79902de6a3 --- /dev/null +++ b/packages/client/ui-primitives/src/SearchBlock.module.css @@ -0,0 +1,125 @@ +/* Geometry mirrors CodeBlock and TerminalBlock (12px radius, code-block + surface + banner row, markdown code-block font) so a search card reads as one + family with them. The deliberate divergence they share: the result rows keep + `white-space: pre` and scroll horizontally, because folding a long match line + or path destroys the alignment a reader scans by. */ + +.block { + --dsl-search-radius: 12px; + --dsl-search-line-height: 22px; + + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-search-radius); +} + +/* The banner: result summary on the left, the truncation pill and copy control + holding their intrinsic width on the right. */ +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 14px; + background: var(--dsw-alias-markdown-code-block-banner); + border-top-left-radius: var(--dsl-search-radius); + border-top-right-radius: var(--dsl-search-radius); +} + +.summary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-secondary); +} + +.truncated { + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.copyButton { + flex: none; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xs-13); +} + +.body { + padding: 8px 14px 12px 0; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; + overflow-y: hidden; +} + +/* No wrapping: a match line or a path keeps its content on one row and scrolls + sideways instead of folding. */ +.line { + min-height: var(--dsl-search-line-height); + padding-left: 14px; + white-space: pre; +} + +/* The 1-based line number ahead of a grep match line, dimmed so the match text + stays the salient content. */ +.lineNumber { + color: var(--dsw-alias-label-tertiary); +} + +/* A file group's header: a bold path label plus its match count, the whole row + the collapse control. */ +.fileHeader { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + min-height: var(--dsl-search-line-height); + padding: 0 14px; + border: none; + background-color: transparent; + cursor: pointer; + font: inherit; + text-align: left; +} + +.filePath { + min-width: 0; + font-weight: 600; + color: var(--dsw-alias-label-primary); + white-space: pre; +} + +.fileCount { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.expand { + display: block; + width: 100%; + padding: 0 14px; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +.empty { + padding: 12px 14px; + font: var(--dsw-font-markdown-code-block); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx new file mode 100644 index 0000000000..98d1edc808 --- /dev/null +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -0,0 +1,263 @@ +// SearchBlock: the search surface for a completed content or path search — a +// banner (result count + a truncation pill when the tool capped the result + +// a copy control), then either grep matches grouped by file (each file a bold +// path header with its `lineNumber: line` rows, the group collapsible) or a +// flat glob path list. Both shapes flatten to one list of rows the height cap +// slices head/tail over, and neither soft-wraps: a long match line or path +// scrolls horizontally instead of folding. Geometry mirrors CodeBlock and +// TerminalBlock so a search card reads as one family with them. + +import { useCallback, useMemo, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import { Pill } from './Pill.tsx' +import css from './SearchBlock.module.css' + +/** + * Result rows shown before the height cap collapses the middle. Matches + * {@link DEFAULT_TERMINAL_MAX_LINES} so a search card and a terminal card cut a + * long result at the same place. + */ +export const DEFAULT_SEARCH_MAX_LINES = 16 + +/** One matched line inside a {@link SearchFileGroup}: its 1-based line number and text. */ +export interface SearchBlockLineMatch { + /** 1-based line number of the match within its file. */ + lineNumber: number + /** The matched line text, as the tool surfaced it. */ + line: string +} + +/** One file's grouped matches, in first-seen file order. */ +export interface SearchFileGroup { + /** The file the matches belong to (the display path). */ + path: string + /** The file's matched lines, in output order. */ + matches: SearchBlockLineMatch[] +} + +/** Fields both search shapes carry (the render site positions; this component draws). */ +interface SearchBlockCommon { + /** + * Whether the tool capped the inline result: the shape carries only the + * retained results, not every result the search found. A truncation pill is + * shown so the card never presents a capped result as complete. + */ + truncated: boolean + /** Total results the search found before capping (equals the retained count when not `truncated`). */ + total: number + /** Height cap in rows before the middle collapses (default {@link DEFAULT_SEARCH_MAX_LINES}). */ + maxLines?: number | undefined + /** Extra class merged onto the wrapper. */ + className?: string | undefined +} + +/** Props for the grouped-matches (`grep`) shape. */ +export interface SearchMatchesBlockProps extends SearchBlockCommon { + kind: 'matches' + /** Matched lines grouped by file, in first-seen file order. */ + files: SearchFileGroup[] +} + +/** Props for the flat-path (`glob`) shape. */ +export interface SearchPathsBlockProps extends SearchBlockCommon { + kind: 'paths' + /** The discovered paths, in the tool's result order (the retained page when `truncated`). */ + paths: string[] +} + +/** {@link SearchBlock} props: one card, two `kind`-discriminated shapes. */ +export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps + +/** + * One flattened render row. A matches card produces a `file` header row per + * group followed by a `match` row per retained line while the group is + * expanded; a paths card produces one `path` row per path. The height cap + * counts these rows uniformly, so a file header costs one row exactly as a + * match line or a path does. + */ +type SearchRow = + | { type: 'file'; path: string; count: number; index: number; collapsed: boolean } + | { type: 'match'; lineNumber: number; line: string; key: string } + | { type: 'path'; path: string } + +/** + * The plain-text form the copy control writes: the whole structured result + * regardless of the height cap or which groups are collapsed, so the clipboard + * carries the result rather than what the card happens to be showing. + * @param props - the card's props. + * @returns the copyable text, or the empty string for an empty result. + */ +function copyText(props: SearchBlockProps): string { + if (props.kind === 'paths') return props.paths.join('\n') + return props.files + .map(file => [file.path, ...file.matches.map(m => `${m.lineNumber}: ${m.line}`)].join('\n')) + .join('\n\n') +} + +/** + * Number of retained results the card holds: the matched-line count across all + * files for a matches card, the path count for a paths card. This is the count + * the truncation pill reports against `total`. + * @param props - the card's props. + * @returns the retained result count. + */ +function shownCount(props: SearchBlockProps): number { + return props.kind === 'paths' + ? props.paths.length + : props.files.reduce((sum, file) => sum + file.matches.length, 0) +} + +/** + * The banner summary: the structural count of the retained result. The + * truncation pill beside it carries the capped-vs-complete signal, so this + * stays a plain count of what the card holds. + * @param props - the card's props. + * @param shown - the retained result count from {@link shownCount}. + * @returns the summary text. + */ +function summaryText(props: SearchBlockProps, shown: number): string { + return props.kind === 'paths' + ? `${shown} 个路径` + : `${shown} 处匹配 · ${props.files.length} 个文件` +} + +/** + * Flatten a card's shape into its render rows, dropping a collapsed file + * group's match rows. + * @param props - the card's props. + * @param collapsed - the set of collapsed file-group indices (matches only). + * @returns the flattened rows in output order. + */ +function toRows(props: SearchBlockProps, collapsed: ReadonlySet): SearchRow[] { + if (props.kind === 'paths') return props.paths.map((path): SearchRow => ({ type: 'path', path })) + const rows: SearchRow[] = [] + props.files.forEach((file, index) => { + const isCollapsed = collapsed.has(index) + rows.push({ type: 'file', path: file.path, count: file.matches.length, index, collapsed: isCollapsed }) + if (isCollapsed) return + for (const match of file.matches) { + rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}` }) + } + }) + return rows +} + +/** + * A stable React key for a flattened render row: the group-scoped match key, a + * file-index-scoped header key, or the path itself. Rows of different types + * never collide, since each key carries its type prefix or the group index. + * @param row - the flattened row. + * @returns the key. + */ +function rowKey(row: SearchRow): string { + switch (row.type) { + case 'match': return `match:${row.key}` + case 'file': return `file:${row.index}` + case 'path': return `path:${row.path}` + } +} + +/** + * Render a completed search as a grouped-matches or flat-path card. + * @param props - see {@link SearchBlockProps}. + * @returns the search block element. + */ +export function SearchBlock(props: SearchBlockProps) { + const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props + const [expanded, setExpanded] = useState(false) + const [collapsed, setCollapsed] = useState>(() => new Set()) + const [copied, setCopied] = useState(false) + + const rows = useMemo(() => toRows(props, collapsed), [props, collapsed]) + const shown = shownCount(props) + const empty = rows.length === 0 + const text = copyText(props) + + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(text).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, text]) + + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + + const toggleFile = useCallback((index: number) => { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(index)) next.delete(index) + else next.add(index) + return next + }) + }, []) + + const hidden = rows.length - maxLines + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed + // tool card), so a long result's head and tail slices agree across surfaces. + const headLines = Math.ceil(maxLines / 2) + const tailLines = maxLines - headLines + + const renderRow = (row: SearchRow): ReactNode => { + if (row.type === 'path') return
{row.path}
+ if (row.type === 'match') { + return ( +
+ {row.lineNumber}: + {row.line} +
+ ) + } + return ( + + ) + } + + return ( +
+
+ {summaryText(props, shown)} + {truncated && {`已截断 · 共 ${total}`}} + {!empty && ( + + )} +
+ {empty + ?
无结果
+ : ( +
+ {(capped ? rows.slice(0, headLines) : rows).map(row => ( +
{renderRow(row)}
+ ))} + {hidden > 0 && ( + + )} + {capped && rows.slice(rows.length - tailLines).map(row => ( +
{renderRow(row)}
+ ))} +
+ )} +
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..2a53f67c1c 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,10 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' +export type { + SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, +} from './SearchBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx new file mode 100644 index 0000000000..37da021663 --- /dev/null +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the +// truncation pill, the empty arm, per-file collapse/expand, the head/tail height +// cap and its expand control, and the copy control writing the whole structured +// result on both the accepted and refused clipboard paths. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts' +import type { SearchFileGroup } from '../src/index.ts' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered result rows, one string per visible row (CSS-module class prefix). */ +function lines(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '') +} + +/** The file-group header rows, one string per header (path + count concatenated). */ +function fileHeaders(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_fileHeader_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered match lines under one file, without a terminating newline. */ +function group(path: string, count: number, from = 1): SearchFileGroup { + return { + path, + matches: Array.from({ length: count }, (_v, i) => ({ lineNumber: from + i, line: `hit ${from + i}` })), + } +} + +describe('SearchBlock matches kind', () => { + it('renders each file as a header group with its matched lines', () => { + const view = render() + expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1']) + expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2']) + // The summary counts matches and files, no truncation pill under the cap. + expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy() + expect(view.queryByText(/已截断/u)).toBeNull() + }) + + it('collapses and re-expands a single file group without touching the others', () => { + const view = render() + const [headerA] = view.container.querySelectorAll('[class^="_fileHeader_"]') + expect(headerA!.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(headerA!) + // a.ts collapsed: its match row is gone, b.ts's stays. + expect(headerA!.getAttribute('aria-expanded')).toBe('false') + expect(lines(view.container)).toEqual(['2: y']) + fireEvent.click(headerA!) + expect(lines(view.container)).toEqual(['1: x', '2: y']) + }) + + it('shows the truncation pill with the pre-cap total', () => { + const view = render() + expect(view.getByText('已截断 · 共 99')).toBeTruthy() + expect(view.getByText('2 处匹配 · 1 个文件')).toBeTruthy() + }) +}) + +describe('SearchBlock paths kind', () => { + it('renders a flat path list with a path-count summary', () => { + const view = render() + expect(lines(view.container)).toEqual(['src/a.ts', 'src/b.ts']) + expect(view.getByText('2 个路径')).toBeTruthy() + // No file-group headers in the paths shape. + expect(fileHeaders(view.container)).toEqual([]) + }) + + it('shows the truncation pill with the pre-cap total', () => { + const view = render() + expect(view.getByText('已截断 · 共 50')).toBeTruthy() + }) +}) + +describe('SearchBlock empty arm', () => { + it('shows the placeholder and no copy control for an empty matches result', () => { + const view = render() + expect(view.getByText('无结果')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + expect(view.getByText('0 处匹配 · 0 个文件')).toBeTruthy() + }) + + it('shows the placeholder for an empty paths result', () => { + const view = render() + expect(view.getByText('无结果')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + }) +}) + +describe('SearchBlock height cap', () => { + it('renders every row and no expand control under the cap', () => { + const view = render() + expect(lines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-label^="展开"]')).toBeNull() + }) + + it('slices head and tail over the cap and expands on click', () => { + const paths = Array.from({ length: 10 }, (_v, i) => `p${i + 1}`) + const view = render() + // maxLines 4: head = ceil(4/2) = 2, tail = 2, 6 hidden. + expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10']) + const toggle = view.getByRole('button', { name: '展开其余 6 行结果' }) + expect(toggle.textContent).toBe('… 其余 6 行') + fireEvent.click(toggle) + expect(lines(view.container)).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起结果' }) + expect(collapse.textContent).toBe('收起') + fireEvent.click(collapse) + expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10']) + }) + + it('counts a file header as one capped row alongside its matches', () => { + // One file with 10 matches → 11 rows (header + 10). Cap 4: head 2, tail 2. + const view = render() + // Head takes the header then the first match; tail takes the last two matches. + expect(lines(view.container)).toEqual(['1: hit 1', '9: hit 9', '10: hit 10']) + expect(fileHeaders(view.container)).toEqual(['a.ts10']) + expect(view.getByRole('button', { name: '展开其余 7 行结果' })).toBeTruthy() + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + expect(lines(view.container)).toEqual(['a']) + expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy() + }) + + it('caps at the documented default when maxLines is absent', () => { + const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`) + const view = render() + expect(lines(view.container)).toHaveLength(DEFAULT_SEARCH_MAX_LINES) + expect(view.getByRole('button', { name: '展开其余 1 行结果' })).toBeTruthy() + }) +}) + +describe('SearchBlock copy', () => { + it('copies the whole structured matches result, not the collapsed or capped view', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render() + // Collapse a group and leave the cap in place: the clipboard still gets it all. + fireEvent.click(view.container.querySelector('[class^="_fileHeader_"]')!) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('a.ts\n1: x\n2: y\n\nb.ts\n3: z') + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // A second click while the ok label shows is a no-op. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('copies the newline-joined path list for the paths shape', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('src/a.ts\nsrc/b.ts') + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when the host refuses the write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) + + it('merges className onto the wrapper and tags the wrapper with the kind', () => { + const view = render() + expect(view.container.firstElementChild?.classList.contains('x')).toBe(true) + expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths') + }) +}) From 2928c65ccd33cc02d012fc3f41be848c82f5e284 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:09:03 +0800 Subject: [PATCH 2/9] feat(web): fold the search truncation total into the summary line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate '已截断 · 共 N' pill with '显示 X / 共 N 处匹配 · K 个文件' (and '显示 X / 共 N 个路径' for glob), mirroring the read card's '显示 X / Y 行', so the retained count and the pre-cap total read as one clause instead of two numbers that appear to disagree. --- .../ui-primitives/src/SearchBlock.module.css | 5 ----- .../client/ui-primitives/src/SearchBlock.tsx | 21 +++++++++++-------- .../ui-primitives/tests/search-block.spec.tsx | 11 +++++----- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-primitives/src/SearchBlock.module.css b/packages/client/ui-primitives/src/SearchBlock.module.css index 79902de6a3..8f46cdb226 100644 --- a/packages/client/ui-primitives/src/SearchBlock.module.css +++ b/packages/client/ui-primitives/src/SearchBlock.module.css @@ -37,11 +37,6 @@ color: var(--dsw-alias-label-secondary); } -.truncated { - flex: none; - color: var(--dsw-alias-state-business-primary); -} - .copyButton { flex: none; background-color: transparent; diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 98d1edc808..dbb4a289ea 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -10,7 +10,6 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from './clipboard.ts' -import { Pill } from './Pill.tsx' import css from './SearchBlock.module.css' /** @@ -109,17 +108,22 @@ function shownCount(props: SearchBlockProps): number { } /** - * The banner summary: the structural count of the retained result. The - * truncation pill beside it carries the capped-vs-complete signal, so this - * stays a plain count of what the card holds. + * The banner summary. When the search was capped it reads `显示 X / 共 N …` so + * the retained count and the pre-cap total sit in one clause (mirroring the read + * card's `显示 X / Y 行`); when it was not capped it is a plain count of what the + * card holds. The unit — `处匹配 · K 个文件` for grep, `个路径` for glob — trails + * the count either way. * @param props - the card's props. * @param shown - the retained result count from {@link shownCount}. + * @param truncated - whether the search was capped. + * @param total - the pre-cap total the truncation clause reports. * @returns the summary text. */ -function summaryText(props: SearchBlockProps, shown: number): string { +function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string { + const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}` return props.kind === 'paths' - ? `${shown} 个路径` - : `${shown} 处匹配 · ${props.files.length} 个文件` + ? `${count} 个路径` + : `${count} 处匹配 · ${props.files.length} 个文件` } /** @@ -227,8 +231,7 @@ export function SearchBlock(props: SearchBlockProps) { return (
- {summaryText(props, shown)} - {truncated && {`已截断 · 共 ${total}`}} + {summaryText(props, shown, truncated, total)} {!empty && ( )} - {capped && rows.slice(rows.length - tailLines).map(row => ( + {tailHeader !== undefined && ( +
{renderRow(tailHeader)}
+ )} + {tail.map(row => (
{renderRow(row)}
))}
diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx index 511b681dc6..45a6664924 100644 --- a/packages/client/ui-primitives/tests/search-block.spec.tsx +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom // SearchBlock: both kinds (grouped grep matches and a flat glob path list), the -// truncation pill, the empty arm, per-file collapse/expand, the head/tail height -// cap and its expand control, and the copy control writing the whole structured +// folded truncation summary, the empty arm, per-file collapse/expand, the +// head/tail height cap and its expand control, the tail slice restoring its +// owning file header, and the copy control writing the whole structured // result on both the accepted and refused clipboard paths. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -41,9 +42,9 @@ describe('SearchBlock matches kind', () => { ]} />) expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1']) expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2']) - // The summary counts matches and files, no truncation pill under the cap. + // The summary counts matches and files, with no folded pre-cap total below the cap. expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy() - expect(view.queryByText(/已截断/u)).toBeNull() + expect(view.queryByText(/显示|共/u)).toBeNull() }) it('collapses and re-expands a single file group without touching the others', () => { @@ -64,7 +65,6 @@ describe('SearchBlock matches kind', () => { it('folds the pre-cap total into the summary when truncated', () => { const view = render() expect(view.getByText('显示 2 / 共 99 处匹配 · 1 个文件')).toBeTruthy() - expect(view.queryByText(/已截断/u)).toBeNull() }) }) @@ -80,7 +80,6 @@ describe('SearchBlock paths kind', () => { it('folds the pre-cap total into the paths summary when truncated', () => { const view = render() expect(view.getByText('显示 2 / 共 50 个路径')).toBeTruthy() - expect(view.queryByText(/已截断/u)).toBeNull() }) }) @@ -139,6 +138,20 @@ describe('SearchBlock height cap', () => { expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy() }) + it('restores the owning file header above a tail slice that begins mid-file', () => { + // Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3 + // matches), tail 4 (last 4 of b.ts, whose header sits above the cut). + const view = render() + // The tail's own header is restored so its rows can be attributed to b.ts. + expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10']) + expect(lines(view.container)).toEqual([ + '1: hit 1', '2: hit 2', '3: hit 3', + '17: hit 17', '18: hit 18', '19: hit 19', '20: hit 20', + ]) + }) + it('caps at the documented default when maxLines is absent', () => { const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`) const view = render() From 6608ede1a033735ea95409995bf0d5bc523f19f8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:49:23 +0800 Subject: [PATCH 4/9] fix(web-search-card): surface result text when an errored search has no card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grep/glob return no presentResult on an error result, so an errored search had no card and the keyed SearchRow showed only a red dot — the model-facing error text (bad pattern, missing path, a nested run_code dispatch with no card) was nowhere on screen. Add an error-text arm mirroring the file-mutation and read rows. Added tests for the text arm and its name/code fallback. The unknown-kind fallback in search-card-model is already guarded (returns null → generic path). --- .../client/toolviews/search-row.module.css | 11 ++++++++ .../src/client/toolviews/search-row.tsx | 25 +++++++++++++++++++ .../tests/search-card.spec.tsx | 19 ++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css index 5c4eb1f7db..dd0395ec1d 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css @@ -93,3 +93,14 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +/* The result text for an errored search, indented to the card's own column and + in the error tone, standing in for the search card the failure path does not + produce. */ +.failure { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 90ec5e3470..0726f30a3c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -40,6 +40,27 @@ function stateStatus(state: ToolRowState): string | null { } } +/** + * A settled result's text, flattened from its content blocks, for the arm that + * shows a failure the search card cannot: grep/glob have no `presentResult` on + * an error result, so an errored search has no card, and the keyed row is not a + * details-panel target. Without this the failure — a bad pattern, a missing + * path, a nested run_code dispatch that returned no card — would read as a bare + * red dot with the model-facing error text nowhere on screen. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +function errorText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} + /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the * completed search's card resident below it. The summary row is not a @@ -51,6 +72,9 @@ export function SearchRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) const status = stateStatus(model.state) + // An errored search has no card (grep/glob return no presentResult on error); + // surface its result text so the failure is more than a red dot. + const failure = search === null && model.state === 'error' ? errorText(block) : null return (
@@ -65,6 +89,7 @@ export function SearchRow({ toolName, block }: ToolRowProps) { {search !== null && ( )} + {failure !== null &&
{failure}
}
) } diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index c2ff38755c..6d566b1b01 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -184,6 +184,25 @@ describe('SearchRow keyed card', () => { expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') }) + it('surfaces the result text when an errored search has no card', () => { + // grep/glob return no presentResult on error → no card; the row shows the + // model-facing error text instead of a bare red dot. + const view = render() + expect(searchKindOf(view.container)).toBeNull() + expect(view.getByText('grep: invalid regular expression')).toBeTruthy() + }) + + it('falls back to the error name/code when an errored result has no text block', () => { + const view = render() + expect(view.getByText('ToolError: timeout')).toBeTruthy() + }) + it('shows the result view\'s replacement title instead of the args summary', () => { const view = render( Date: Thu, 30 Jul 2026 22:40:07 +0800 Subject: [PATCH 5/9] fix(web-search-card): surface truncation recovery, widen cardless fallback, validate wire shape, fix tail-cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the ds-review-bot findings on the search card: - searchCardModel dropped the result view's `content`, so a capped search's `Full … stored at: ` recovery footer vanished from the UI (the card replaces the raw text). Thread it through as `SearchCardModel.recovery` and render it below the card at all three sites, only when truncated. - SearchRow's fallback body was gated on `state === 'error'`, so a settled non-error call with no card (a successful nested run_code sub-dispatch, a legacy generic result) showed only its summary with content lost. Widen it to any settled call with `search === null`. - searchCardModel trusted the `files`/`paths` shape the host wire schema only string-checks; a malformed known-kind frame would crash SearchBlock. Validate the full shape and fall to the generic path on mismatch. - SearchBlock's restored tail file header added a row without consuming a tail slot, exceeding maxLines by one and overstating the hidden count. Make it consume a slot so the visible count holds at maxLines and `hidden` stays exact. Correct the fixture JSDoc (now genuinely exceeds the row cap) and the Agent Note recovery-text claim, sync the ui-conversation bilingual README with the search row, and add an assembled keyless snapshot (apps/web/tests/search-card.snapshot.ts) that pins the grep card's shape from the built bundles. --- .../2026-07-30-web-search-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-search-card.md | 13 +- .../feature/2026-07-30-web-search-card.zh.md | 13 +- apps/web/tests/search-card.snapshot.ts | 161 ++++++++++++++++++ .../search-card/grep-card.expected.txt | 11 ++ .../client/connection/src/client/fixture.ts | 11 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/chat/ToolRow.module.css | 11 ++ .../src/client/chat/ToolRow.tsx | 11 +- .../src/client/contract/search-card-model.ts | 72 +++++++- .../client/skeleton/DetailsPanel.module.css | 11 ++ .../src/client/skeleton/DetailsPanel.tsx | 18 +- .../client/toolviews/search-row.module.css | 11 ++ .../src/client/toolviews/search-row.tsx | 39 +++-- .../tests/search-card.spec.tsx | 98 +++++++++++ .../client/ui-primitives/src/SearchBlock.tsx | 10 +- .../ui-primitives/tests/search-block.spec.tsx | 10 +- 19 files changed, 470 insertions(+), 42 deletions(-) create mode 100644 apps/web/tests/search-card.snapshot.ts create mode 100644 apps/web/tests/snapshots/search-card/grep-card.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 9edc74a0d2..179580e4d2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-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-30-web-search-card.md -2026-07-30-web-search-card.md: 1dff5ae5a4d789b1e57fcaef349959764583fbdf -2026-07-30-web-search-card.zh.md: 09d38066bf16923655b27a30c717d97ccbe434bb +2026-07-30-web-search-card.md: a3e3d7c3da1f686b4147e629fb4724d7750f8b6c +2026-07-30-web-search-card.zh.md: c333ebf434f2f6798c2e1758e4a534dc35ea2ef9 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 1dff5ae5a4..a3e3d7c3da 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -12,7 +12,7 @@ This is the follow-up the search render card note names: that PR was the backend ## Decision -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, and a `card` value this client version does not know. +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `kind` this version does not compile, and — because `kind` and the grouped/flat shape ride the same untrusted wire frame the host schema only string-checks — a known `kind` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. @@ -22,7 +22,8 @@ The component's contract: - **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text. - **Flat path list.** The paths shape renders one path per row, no headers. -- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result; a reader who wants the rest follows the spill locator in the model-facing text, exactly as the model does. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). +- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). +- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the result view's `content` text, not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces that flattened `content` as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its `content` adds nothing and is dropped. - **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding. - **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`. - **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing. @@ -33,9 +34,9 @@ Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search Three sites consume the derivation, mirroring the terminal card's placement exactly: -- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) -- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card behind the row's expand toggle. -- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, keeping the JSON Input section. +- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) +- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle. +- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section. `CHAT_SEARCH_MAX_LINES` (8) is the row cap, half the primitive's default the panel keeps, for the same reason as `CHAT_TERMINAL_MAX_LINES`: the chat flow is a summary surface read across many calls, the panel is the single-call reading surface. @@ -55,7 +56,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac `packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, and each null arm (running, no views, generic, terminal, unknown card); the chat row's expand-gated matches and paths bodies through `GenericToolCard` against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` and a `glob` turn emitting `kind: 'paths'` as `resultView`, both truncated, driving the built-boot snapshot and the live `?fixture` server. +`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index 09d38066bf..c333ebf434 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图,以及本客户端版本不认识的 `card` 值。 +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`kind` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `kind` 和分组/扁平形态与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `kind` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。 与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 @@ -22,7 +22,8 @@ Status: implemented - **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 - **扁平路径列表。** paths 形态每行一个路径,无头行。 -- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果;想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: ` 脚注 —— 只存在于结果视图的 `content` 文本里,而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把压平后的 `content` 作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其 `content` 不增加任何信息,因此被丢弃。 - **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 - **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 - **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 @@ -33,9 +34,9 @@ Status: implemented 三个渲染点消费该推导,与终端卡片的落位完全一致: -- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) -- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片。 -- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,保留 JSON Input 段。 +- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) +- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。 +- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。 `CHAT_SEARCH_MAX_LINES`(8)是行内上限,为 primitive 默认值的一半(panel 保留默认值),理由与 `CHAT_TERMINAL_MAX_LINES` 相同:chat 流是跨多次调用扫读的摘要表面,panel 是单次调用的阅读表面。 @@ -55,7 +56,7 @@ Status: implemented `packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片);通过 `GenericToolCard` 的展开门控 matches 与 paths body,对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind,对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn 与一个发出 `kind: 'paths'` 的 `glob` turn 作为 `resultView`,两者都截断,驱动 built-boot snapshot 与实时 `?fixture` 服务。 +`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。 ## Related diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts new file mode 100644 index 0000000000..7eca80d8bf --- /dev/null +++ b/apps/web/tests/search-card.snapshot.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +// Assembled search-card snapshot: boots the real built `packages/client/*/lib/ +// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless +// FixtureApiClient transport (no API key, no model round), opens the fixture +// session, and pins the search card the `grep` turn (fixture turn 66) renders in +// the assembled application. The built-boot smoke proves the graph boots but +// carries no behavior assertions by contract; this is the assembled-output check +// that a broken SearchRow registration or a dropped card would fail — the +// per-package suites bench over src and cannot see the bundled wiring. +// +// Keyless and deterministic: the fixture is the fake server, so the grep turn's +// matches, its truncation summary, and its head/tail cap are fixed in the +// fixture, not harvested from a live model. The recovery-footer arm is a pure +// derivation over the result view, pinned at every render site by the +// ui-conversation suite; here the fixture turn exercises the assembled card +// shape and its cap. +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') +const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +/** Normalize a rendered search card to a stable text shape: the kind, the banner + * summary, each file header (path + count), each visible match line, the expand + * control label, and the recovery footer. CSS-module class names carry a + * per-build hash in one of two schemes — ui-primitives emits `__` + * (name bounded by underscores), ui-conversation emits `_` (name at + * the end). `hasClass` matches a module class by its logical name under either, + * without matching a longer name that contains it (`line` must not hit + * `lineNumber`). */ +function hasClass(el: Element, name: string): boolean { + return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) +} + +function cardShape(root: Element): string { + const card = root.querySelector('[data-search]') + if (card === null) return '' + const pick = (from: Element, name: string): Element[] => + [...from.querySelectorAll('*')].filter(el => hasClass(el, name)) + const lines: string[] = [`kind=${card.getAttribute('data-search')}`] + const summary = pick(card, 'summary')[0]?.textContent?.trim() + if (summary !== undefined && summary !== '') lines.push(`summary=${summary}`) + for (const header of pick(card, 'fileHeader')) lines.push(`file=${header.textContent?.trim() ?? ''}`) + for (const row of pick(card, 'line')) lines.push(`line=${row.textContent?.trim() ?? ''}`) + const expand = pick(card, 'expand')[0]?.textContent?.trim() + if (expand !== undefined && expand !== '') lines.push(`expand=${expand}`) + const recovery = pick(root, 'searchRecovery')[0]?.textContent?.trim() + if (recovery !== undefined && recovery !== '') lines.push(`recovery=${recovery}`) + return lines.join('\n') +} + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +describe('assembled search card', () => { + it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + // Wait for chat content to reach the fixture's later turns (the bash sample + // is turn 65, the grep card turn 66). + await waitFor(() => { + expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() + }, { timeout: 10_000 }) + // The grep turn's keyed SearchRow renders the card resident: wait for it. + await waitFor(() => { + const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool')) + expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep') + }, { timeout: 10_000 }) + + // `data-tool` sits on the summary row; the card and recovery footer are its + // siblings inside the SearchRow wrapper, so shape the wrapper (its parent). + const grepRow = document.querySelector('[data-tool="grep"]')!.parentElement! + const shape = cardShape(grepRow) + if (refreshing) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + }) +}) diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt new file mode 100644 index 0000000000..3d0efb3ecd --- /dev/null +++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt @@ -0,0 +1,11 @@ +kind=matches +summary=显示 9 / 共 42 处匹配 · 3 个文件 +file=packages/client/ui-primitives/src/SearchBlock.tsx3 +file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4 +line=16: export const DEFAULT_SEARCH_MAX_LINES = 16 +line=138: export function SearchBlock(props: SearchBlockProps) { +line=141: const [collapsed, setCollapsed] = useState>(() => new Set()) +line=73: const search = searchCardModel(block) +line=90: +line=113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) +expand=… 其余 4 行 \ No newline at end of file diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 64c4229e65..46a17332ae 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -159,6 +159,15 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin { lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' }, ], }, + { + path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx', + matches: [ + { lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' }, + { lineNumber: 73, line: ' const search = searchCardModel(block)' }, + { lineNumber: 90, line: ' ' }, + { lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" }, + ], + }, ] /** @@ -169,7 +178,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin * `Line N:` rows, then a spill-recovery footer. */ const SEARCH_MATCHES_TEXT = [ - 'Found 5 of 42 matches', + 'Found 9 of 42 matches', '', ...SEARCH_MATCHES_FIXTURE.map(file => [file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..c5b5d63e9f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: e5e006b761020b8dfaf25eac191d1745326ae1f6 +README.zh.md: 3b19e2b5a0e9cc764bad74671e16c4428452e61f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..e5e006b761 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,6 +16,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). + Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..3b19e2b5a0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,6 +14,8 @@ 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 + 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index e046313128..a02608c11b 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -125,6 +125,17 @@ margin: 4px 0 4px 22px; } +/* The recovery footer for a capped search: the result text (its `Full … stored + at …` locator) below the card in the muted tone, since the card holds only the + retained rows. Same column indent as the card body. */ +.searchRecovery { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + /* Indented to the body's own column so the description reads as the card's heading rather than as another summary row, and sits tight against the card below it. Its own rule: grouping it with a body would put description diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 596ea0e1cd..37a6350a21 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -137,7 +137,16 @@ export function ToolRow({ {terminalBody !== null ? : searchBody !== null - ? + ? ( + <> + + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
+ )} + + ) : variant === 'code' ? :
{text}
} diff --git a/packages/client/ui-conversation/src/client/contract/search-card-model.ts b/packages/client/ui-conversation/src/client/contract/search-card-model.ts index 9bb65a3092..f871e7d6dc 100644 --- a/packages/client/ui-conversation/src/client/contract/search-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/search-card-model.ts @@ -12,9 +12,15 @@ * therefore reads only `resultView` and returns null for a still-running call, * unlike the terminal card whose call view carries the command before * execution. + * + * A capped result also carries a recovery locator (grep/glob's `Full … stored + * at …` footer) that lives only in the view's `content` text, not in the + * structured matches/paths. Since both render sites replace the raw result with + * the card, this derivation surfaces that text as {@link SearchCardModel.recovery} + * so the one path to the dropped rows is not lost. * @module */ -import type { SearchBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' /** @@ -55,6 +61,54 @@ export interface SearchCardModel { * row then keeps its args-derived summary. */ title: string | undefined + /** + * The model-facing result text (the view's `content`, flattened), surfaced + * only when the search was capped. The card renders the retained matches or + * paths, but the recovery locator a capped result carries — grep/glob's + * `Full … stored at: ` footer, the one way to reach the rows the cap + * dropped — lives only in this text. A UI that replaces the raw result with + * the card would otherwise lose it. Absent when the result was not capped + * (the card holds every result) or the presenter supplied no content. + */ + recovery: string | undefined +} + +/** + * Whether every file group in a matches view is structurally valid: the wire + * frame carries `kind` and `card` as strings the host schema checks, but not the + * grouped shape, so a version mismatch or loose producer could deliver + * `kind: 'matches'` with a missing or malformed `files`. Rendering that would + * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the + * generic path instead. + * @param files - the candidate `files` field off the untrusted result view. + * @returns whether `files` is a valid {@link SearchFileGroup} array. + */ +function isValidFiles(files: unknown): files is SearchFileGroup[] { + return Array.isArray(files) && files.every(file => + typeof file === 'object' && file !== null + && typeof (file as { path?: unknown }).path === 'string' + && Array.isArray((file as { matches?: unknown }).matches) + && (file as { matches: unknown[] }).matches.every(match => + typeof match === 'object' && match !== null + && typeof (match as { lineNumber?: unknown }).lineNumber === 'number' + && typeof (match as { line?: unknown }).line === 'string')) +} + +/** + * Flatten a result view's `content` blocks to their text, joined by newlines. + * The search views carry `content` (the model-facing result text) so a UI + * without a search card can show it; here it is the source of the truncation + * recovery footer. Non-text blocks (a search result carries none) are skipped. + * @param content - the result view's optional content blocks. + * @returns the joined text, or undefined when absent or empty. + */ +function flattenContent(content: readonly { type: string; text?: string }[] | undefined): string | undefined { + if (content === undefined) return undefined + const text = content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string') + .map(block => block.text) + .join('\n') + return text === '' ? undefined : text } /** @@ -78,8 +132,17 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { const result = block.resultView?.card === 'search' ? block.resultView : null if (result === null) return null const common = { truncated: result.truncated, total: result.total } + // The recovery footer only matters when the tool capped the result: an + // uncapped card holds every match/path, so its content adds nothing the card + // does not already show. When capped, the content's `Full … stored at …` + // locator is the only path to the dropped rows, so surface it. + const recovery = result.truncated ? flattenContent(result.content) : undefined if (result.kind === 'matches') { - return { title: result.title, card: { kind: 'matches', files: result.files, ...common } } + // `files` rides the untrusted wire frame: the host schema checks `card`/`kind` + // strings but not the grouped shape, so validate it before SearchBlock, which + // would crash on a missing/malformed `files`. An invalid shape falls to generic. + if (!isValidFiles(result.files)) return null + return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } // `kind` rides the same untrusted wire frame as `card`, so a version mismatch // or a loose protocol producer could deliver a `card: 'search'` subtype this @@ -88,5 +151,8 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { // would leave SearchBlock calling `.length`/`.map` on an absent `paths`. // oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive. if (result.kind !== 'paths') return null - return { title: result.title, card: { kind: 'paths', paths: result.paths, ...common } } + // `paths` is likewise unchecked by the wire schema; a known kind with a + // missing/malformed array would crash the paths card at `.map`. + if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null + return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } } } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index cb0c301c1b..1efca98969 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -107,3 +107,14 @@ .terminal { margin: 0; } + +/* The recovery footer for a capped search: the result text (its `Full … stored + at …` locator) below the card in the muted tone, since the card holds only the + retained rows. */ +.searchRecovery { + margin: 6px 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index e164d955b9..8cc14cb4a0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -130,8 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * at the primitive's own full height allowance, so column-aligned output keeps * its alignment and scrolls sideways instead of folding. A search-card call — * a `grep`/`glob` result view — renders through the shared SearchBlock at the - * same full height allowance. Every other call, and a running call with no card - * yet, keeps the flattened text form. + * same full height allowance, with a capped search's recovery footer below it. + * Every other call, and a running call with no card yet, keeps the flattened + * text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -151,7 +152,18 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } const search = searchCardModel(material.block) - if (search !== null) return + if (search !== null) { + return ( + <> + + {/* A capped search's recovery locator lives only in the result text; + show it below the card so the dropped rows stay reachable. */} + {search.recovery !== undefined && ( +
{search.recovery}
+ )} + + ) + } // 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/search-row.module.css b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css index dd0395ec1d..21908bd9e1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css @@ -104,3 +104,14 @@ font: var(--dsw-font-xs-13); color: var(--dsw-alias-state-error-primary); } + +/* The recovery footer for a capped search: the model-facing result text (its + `Full … stored at …` locator) shown below the card in the muted tone, since + the card holds only the retained rows. Same column indent as the card body. */ +.recovery { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 0726f30a3c..8c0181ba78 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -42,11 +42,14 @@ function stateStatus(state: ToolRowState): string | null { /** * A settled result's text, flattened from its content blocks, for the arm that - * shows a failure the search card cannot: grep/glob have no `presentResult` on - * an error result, so an errored search has no card, and the keyed row is not a - * details-panel target. Without this the failure — a bad pattern, a missing - * path, a nested run_code dispatch that returned no card — would read as a bare - * red dot with the model-facing error text nowhere on screen. + * shows a result the search card cannot. Two cases reach it: an errored search + * (grep/glob emit no `presentResult` on an error result, so an errored search + * has no card), and a settled call whose result view is not a search card at all + * — a nested `run_code` sub-dispatch (the backend computes no presentationMeta + * for it, so `resultView` is null) or a legacy generic result. In both the keyed + * SearchRow owns the render slot, so without this arm the model-facing text would + * have nowhere to go: an errored search would read as a bare red dot, and a + * successful cardless result would show only its summary with its content lost. * @param block - the frozen call slice. * @returns the result text, or null for a running call or an empty result. */ @@ -63,18 +66,24 @@ function errorText(block: ToolRowProps['block']): string | null { /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the - * completed search's card resident below it. The summary row is not a - * details-panel control, so the card's copy, per-file collapse, and expand - * controls are the row's only interactions. Registered under both `grep` and - * `glob`; the derived model's `kind` decides the card shape. + * completed search's card resident below it, and — when the result was capped — + * the recovery footer below the card. The summary row is not a details-panel + * control, so the card's copy, per-file collapse, and expand controls are the + * row's only interactions. Registered under both `grep` and `glob`; the derived + * model's `kind` decides the card shape. */ export function SearchRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) const status = stateStatus(model.state) - // An errored search has no card (grep/glob return no presentResult on error); - // surface its result text so the failure is more than a red dot. - const failure = search === null && model.state === 'error' ? errorText(block) : null + // A settled call with no search card — an errored search (grep/glob emit no + // result view on error), a successful nested run_code sub-dispatch, or a + // legacy generic result — has its model-facing text nowhere else to go, since + // the keyed SearchRow owns this render slot. Surface it as the fallback body. + // A running call ('kind' absent) has no result to flatten; errorText returns + // null for it, so the arm stays closed until settle. + const settled = 'kind' in block + const fallback = search === null && settled ? errorText(block) : null return (
@@ -89,7 +98,11 @@ export function SearchRow({ toolName, block }: ToolRowProps) { {search !== null && ( )} - {failure !== null &&
{failure}
} + {/* A capped search drops rows from the card; its recovery locator (the + `Full … stored at …` footer) lives only in the result text, so show it + below the card so the one path to the dropped rows survives. */} + {search?.recovery !== undefined &&
{search.recovery}
} + {fallback !== null &&
{fallback}
}
) } diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index 6d566b1b01..26eb16a7f9 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -77,6 +77,7 @@ describe('searchCardModel', () => { it('derives a matches card from the grep result view', () => { expect(searchCardModel(settledGrep())).toEqual({ title: undefined, + recovery: undefined, card: { kind: 'matches', files: [ @@ -91,6 +92,7 @@ describe('searchCardModel', () => { it('derives a paths card from the glob result view, carrying the truncation signal', () => { expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ title: undefined, + recovery: undefined, card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, }) }) @@ -115,6 +117,55 @@ describe('searchCardModel', () => { const future = { card: 'chart' } as unknown as ToolResultView expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() }) + + it('returns null for a card:search view whose kind this version does not compile', () => { + // `kind` rides the same untrusted wire frame as `card`; a subtype this client + // does not know must fall to the generic path, never render as a paths card + // that would crash SearchBlock on an absent `paths`. + const futureKind = { + card: 'search', kind: 'future', truncated: false, total: 0, + } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: futureKind }))).toBeNull() + }) + + it('returns null for a known kind whose structured shape is missing or malformed', () => { + // The host wire schema checks the `card`/`kind` strings but not the grouped + // shape, so a version mismatch could deliver kind:'matches' with no `files` + // (or kind:'paths' with no `paths`). Rendering that crashes SearchBlock at + // `.reduce`/`.map`; the derivation drops to the generic path instead. + const noFiles = { card: 'search', kind: 'matches', truncated: false, total: 0 } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull() + const badFile = { + card: 'search', kind: 'matches', truncated: false, total: 1, + files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }], + } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull() + const noPaths = { card: 'search', kind: 'paths', truncated: false, total: 0 } as unknown as ToolResultView + expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull() + const badPaths = { + card: 'search', kind: 'paths', truncated: false, total: 1, paths: [42], + } as unknown as ToolResultView + expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull() + }) + + it('surfaces the recovery text only when the result was capped', () => { + const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' + // Capped: the content (its `Full … stored at …` locator) rides through so the + // dropped rows stay reachable. + const capped = searchCardModel(settledGrep({ + resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }), + })) + expect(capped?.recovery).toBe(recovery) + // Not capped: the card holds every match, so the content adds nothing and is + // dropped. + const whole = searchCardModel(settledGrep({ + resultView: resultMatches({ truncated: false, content: [{ type: 'text', text: recovery }] }), + })) + expect(whole?.recovery).toBeUndefined() + // Capped but the presenter attached no content: nothing to surface. + const noContent = searchCardModel(settledGrep({ resultView: resultMatches({ truncated: true, total: 42 }) })) + expect(noContent?.recovery).toBeUndefined() + }) }) describe('chat row search body (GenericToolCard fallback)', () => { @@ -150,6 +201,16 @@ describe('chat row search body (GenericToolCard fallback)', () => { expect(view.getByText(/"pattern"/)).toBeTruthy() expect(searchKindOf(view.container)).toBeNull() }) + + it('the expanded body shows the recovery footer below a capped card', () => { + const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(searchKindOf(view.container)).toBe('matches') + expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() + }) }) describe('SearchRow keyed card', () => { @@ -195,6 +256,34 @@ describe('SearchRow keyed card', () => { expect(view.getByText('grep: invalid regular expression')).toBeTruthy() }) + it('surfaces the result text for a settled non-error call with no card', () => { + // A successful nested run_code sub-dispatch (backend computes no + // presentationMeta, so resultView is null) or a legacy generic result settles + // with search === null and state ok. The keyed SearchRow owns the slot, so + // without the widened arm the content would be lost behind a bare summary. + const view = render() + expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok') + expect(searchKindOf(view.container)).toBeNull() + expect(view.getByText('nested run_code output line')).toBeTruthy() + }) + + it('renders the recovery footer below the card when the search was capped', () => { + const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' + const view = render() + expect(searchKindOf(view.container)).toBe('matches') + expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() + }) + + it('shows no recovery footer for an uncapped search', () => { + const view = render() + expect(view.container.textContent).not.toMatch(/stored at/) + }) + it('falls back to the error name/code when an errored result has no text block', () => { const view = render( { expect(searchKindOf(view.container)).toBe('paths') }) + it('renders the recovery footer below the card for a capped search', () => { + const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)' + const view = mount(snapshot({ + nodes: [settledGlob({ resultView: resultPaths({ truncated: true, total: 23, content: [{ type: 'text', text: recovery }] }) })], + }), globTarget) + expect(searchKindOf(view.container)).toBe('paths') + expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy() + }) + it('a non-search result keeps the flattened pre form', () => { const view = mount(snapshot({ nodes: [settledGrep({ callView: null, resultView: null })], diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 843b947491..5210b2fdc6 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -209,17 +209,23 @@ export function SearchBlock(props: SearchBlockProps) { const headLines = Math.ceil(maxLines / 2) const tailLines = maxLines - headLines const head = capped ? rows.slice(0, headLines) : rows - const tail = capped ? rows.slice(rows.length - tailLines) : [] + const naturalTail = capped ? rows.slice(rows.length - tailLines) : [] // When the tail slice begins inside a file's matches, its own header sits // above the cut and is not shown, so those rows could not be attributed to a // file. Restore the owning header at the top of the tail — unless the head // slice already carries it (a single large file), where it would duplicate. - const tailLead = tail[0] + const tailLead = naturalTail[0] const tailHeader = tailLead?.type === 'match' && !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex) ? rows.find((row): row is Extract => row.type === 'file' && row.index === tailLead.fileIndex) : undefined + // The restored header is itself a row. Left extra it would push the card to + // maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop + // the tail's first row (the match whose header this is) for it. Visible rows + // hold at maxLines and `hidden` stays exact; the dropped match joins the + // hidden middle. + const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1) const renderRow = (row: SearchRow): ReactNode => { if (row.type === 'path') return
{row.path}
diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx index 45a6664924..29cf87fb91 100644 --- a/packages/client/ui-primitives/tests/search-block.spec.tsx +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -140,16 +140,20 @@ describe('SearchBlock height cap', () => { it('restores the owning file header above a tail slice that begins mid-file', () => { // Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3 - // matches), tail 4 (last 4 of b.ts, whose header sits above the cut). + // matches), tail 4. The tail begins mid-b.ts, so its header is restored — + // and, being a row itself, it consumes one tail slot rather than pushing the + // card to 9 rows: the tail keeps its last 3 matches, total visible = 8. const view = render() - // The tail's own header is restored so its rows can be attributed to b.ts. expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10']) expect(lines(view.container)).toEqual([ '1: hit 1', '2: hit 2', '3: hit 3', - '17: hit 17', '18: hit 18', '19: hit 19', '20: hit 20', + '18: hit 18', '19: hit 19', '20: hit 20', ]) + // Visible rows hold at maxLines (2 headers + 6 matches = 8), so the hidden + // count stays exact: 22 − 8 = 14. + expect(view.getByRole('button', { name: '展开其余 14 行结果' })).toBeTruthy() }) it('caps at the documented default when maxLines is absent', () => { From 4a4ec6fd4d3f1a36d5f60726befdc49beb2621d6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:49:41 +0800 Subject: [PATCH 6/9] fix(web-search-card): follow base rename kind->shape and view-drops-content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base (feat/search-presenter) renamed the search result view's discriminant from `kind` to `shape` and removed the view's `content` field (a UI without a card now falls back to the raw tool/result content). Adapt the web consumer: - searchCardModel switches on `result.shape`; SearchBlock's own `kind` prop is mapped from it. - The truncation recovery footer reads the block's raw `content` (where the `Full … stored at …` locator now lives) instead of the removed view content. - Fixture grep/glob views use `shape` and drop `content`; the recovery footer rides the raw tool/result text. - Tests and the bilingual Agent Note follow the rename and the recovery source. --- .../2026-07-30-web-search-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-search-card.md | 6 +- .../feature/2026-07-30-web-search-card.zh.md | 6 +- .../client/connection/src/client/fixture.ts | 18 ++--- .../src/client/contract/search-card-model.ts | 65 ++++++++++--------- .../tests/search-card.spec.tsx | 65 ++++++++++--------- 6 files changed, 83 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 179580e4d2..a6658971b1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-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-30-web-search-card.md -2026-07-30-web-search-card.md: a3e3d7c3da1f686b4147e629fb4724d7750f8b6c -2026-07-30-web-search-card.zh.md: c333ebf434f2f6798c2e1758e4a534dc35ea2ef9 +2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1 +2026-07-30-web-search-card.zh.md: 714a2979730dc2c83f6cfc1cf6d21978755a2d95 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index a3e3d7c3da..4c7ae6c8c6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -6,13 +6,13 @@ English | [中文](2026-07-30-web-search-card.zh.md) ## Problem -The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`kind: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`kind: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text. +The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`shape: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`shape: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text. This is the follow-up the search render card note names: that PR was the backend contract and its two producers; this PR is the web consumer. ## Decision -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `kind` this version does not compile, and — because `kind` and the grouped/flat shape ride the same untrusted wire frame the host schema only string-checks — a known `kind` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation. The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. @@ -23,7 +23,7 @@ The component's contract: - **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text. - **Flat path list.** The paths shape renders one path per row, no headers. - **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). -- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the result view's `content` text, not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces that flattened `content` as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its `content` adds nothing and is dropped. +- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the raw `tool/result` content (the search view carries no result text; a UI without a card falls back to that raw content), not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces the block's own flattened result text as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its raw text adds nothing and is dropped. - **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding. - **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`. - **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index c333ebf434..714a297973 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -6,13 +6,13 @@ Status: implemented ## Problem -`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`kind: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`kind: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。 +`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`shape: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`shape: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。 这正是 search render card note 指名的后续:那个 PR 是后端契约和它的两个生产者,本 PR 是 web 消费者。 ## Decision -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`kind` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `kind` 和分组/扁平形态与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `kind` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。 +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。 与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 @@ -23,7 +23,7 @@ Status: implemented - **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 - **扁平路径列表。** paths 形态每行一个路径,无头行。 - **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 -- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: ` 脚注 —— 只存在于结果视图的 `content` 文本里,而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把压平后的 `content` 作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其 `content` 不增加任何信息,因此被丢弃。 +- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: ` 脚注 —— 只存在于原始 `tool/result` 内容里(搜索视图不携带结果文本;没有卡片的 UI 回退到那段原始内容),而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把 block 自身压平后的结果文本作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其原始文本不增加任何信息,因此被丢弃。 - **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 - **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 - **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 46a17332ae..f41a6ed2f7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -435,20 +435,16 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR const call = presentCall(name, argsRaw) if (call === undefined) return undefined // Search is result-time only: the call stays a generic search card, and the - // result view carries the structured shape the card renders, with the - // model-facing text as `content` for a UI without a search card. `total` - // exceeds the retained count so the card shows its capped indicator. + // result view carries the structured shape the card renders. The view holds no + // result text — a UI without a search card falls back to the raw tool/result + // content — so the truncation recovery footer rides that raw content (the + // `toolTurn` message text), not the view. `total` exceeds the retained count so + // the card shows its capped indicator. if (name === 'grep') { - return { - card: 'search', kind: 'matches', files: SEARCH_MATCHES_FIXTURE, - truncated: true, total: 42, content: text(resultText), - } + return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 } } if (name === 'glob') { - return { - card: 'search', kind: 'paths', paths: SEARCH_PATHS_FIXTURE, - truncated: true, total: 23, content: text(resultText), - } + return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 } } switch (call.card) { case 'terminal': diff --git a/packages/client/ui-conversation/src/client/contract/search-card-model.ts b/packages/client/ui-conversation/src/client/contract/search-card-model.ts index f871e7d6dc..08d6389686 100644 --- a/packages/client/ui-conversation/src/client/contract/search-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/search-card-model.ts @@ -14,10 +14,11 @@ * execution. * * A capped result also carries a recovery locator (grep/glob's `Full … stored - * at …` footer) that lives only in the view's `content` text, not in the - * structured matches/paths. Since both render sites replace the raw result with - * the card, this derivation surfaces that text as {@link SearchCardModel.recovery} - * so the one path to the dropped rows is not lost. + * at …` footer) in the raw `tool/result` content, not in the structured + * matches/paths the view carries. Since both render sites replace that raw + * result with the card, this derivation surfaces the block's own result text as + * {@link SearchCardModel.recovery} so the one path to the dropped rows is not + * lost. * @module */ import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives' @@ -62,22 +63,22 @@ export interface SearchCardModel { */ title: string | undefined /** - * The model-facing result text (the view's `content`, flattened), surfaced - * only when the search was capped. The card renders the retained matches or - * paths, but the recovery locator a capped result carries — grep/glob's - * `Full … stored at: ` footer, the one way to reach the rows the cap - * dropped — lives only in this text. A UI that replaces the raw result with - * the card would otherwise lose it. Absent when the result was not capped - * (the card holds every result) or the presenter supplied no content. + * The raw `tool/result` text, flattened, surfaced only when the search was + * capped. The card renders the retained matches or paths, but the recovery + * locator a capped result carries — grep/glob's `Full … stored at: ` + * footer, the one way to reach the rows the cap dropped — lives only in the raw + * result text, which the card replaces. A UI that shows the card would + * otherwise lose it. Absent when the result was not capped (the card holds + * every result) or the block carries no text. */ recovery: string | undefined } /** * Whether every file group in a matches view is structurally valid: the wire - * frame carries `kind` and `card` as strings the host schema checks, but not the + * frame carries `shape` and `card` as strings the host schema checks, but not the * grouped shape, so a version mismatch or loose producer could deliver - * `kind: 'matches'` with a missing or malformed `files`. Rendering that would + * `shape: 'matches'` with a missing or malformed `files`. Rendering that would * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the * generic path instead. * @param files - the candidate `files` field off the untrusted result view. @@ -95,15 +96,15 @@ function isValidFiles(files: unknown): files is SearchFileGroup[] { } /** - * Flatten a result view's `content` blocks to their text, joined by newlines. - * The search views carry `content` (the model-facing result text) so a UI - * without a search card can show it; here it is the source of the truncation - * recovery footer. Non-text blocks (a search result carries none) are skipped. - * @param content - the result view's optional content blocks. - * @returns the joined text, or undefined when absent or empty. + * Flatten a settled tool result's content blocks to their text, joined by + * newlines. The search view carries no result text — a UI without a card falls + * back to the raw `tool/result` content — so the truncation recovery footer is + * read from the block's own content here. Non-text blocks (a search result + * carries none) are skipped. + * @param content - the result node's content blocks. + * @returns the joined text, or undefined when empty. */ -function flattenContent(content: readonly { type: string; text?: string }[] | undefined): string | undefined { - if (content === undefined) return undefined +function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined { const text = content .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string') .map(block => block.text) @@ -119,7 +120,7 @@ function flattenContent(content: readonly { type: string; text?: string }[] | un * a still-running call (no result view) is null, as is a settled call whose * result view is not a search card — including a `card` value this UI version * does not know, which arrives over the wire and cannot be trusted to be one of - * the compiled variants, a `card: 'search'` view whose `kind` is neither + * the compiled variants, a `card: 'search'` view whose `shape` is neither * `matches` nor `paths` (equally untrusted wire data), and a generic result a * `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps * the generic path). @@ -133,25 +134,25 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { if (result === null) return null const common = { truncated: result.truncated, total: result.total } // The recovery footer only matters when the tool capped the result: an - // uncapped card holds every match/path, so its content adds nothing the card - // does not already show. When capped, the content's `Full … stored at …` + // uncapped card holds every match/path, so the raw text adds nothing the card + // does not already show. When capped, the raw result's `Full … stored at …` // locator is the only path to the dropped rows, so surface it. - const recovery = result.truncated ? flattenContent(result.content) : undefined - if (result.kind === 'matches') { - // `files` rides the untrusted wire frame: the host schema checks `card`/`kind` + const recovery = result.truncated ? flattenContent(block.content) : undefined + if (result.shape === 'matches') { + // `files` rides the untrusted wire frame: the host schema checks `card`/`shape` // strings but not the grouped shape, so validate it before SearchBlock, which // would crash on a missing/malformed `files`. An invalid shape falls to generic. if (!isValidFiles(result.files)) return null return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } - // `kind` rides the same untrusted wire frame as `card`, so a version mismatch + // `shape` rides the same untrusted wire frame as `card`, so a version mismatch // or a loose protocol producer could deliver a `card: 'search'` subtype this - // client does not compile. Guard the paths shape explicitly: an unknown kind + // client does not compile. Guard the paths shape explicitly: an unknown shape // falls to the generic path rather than being rendered as a paths card, which // would leave SearchBlock calling `.length`/`.map` on an absent `paths`. - // oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive. - if (result.kind !== 'paths') return null - // `paths` is likewise unchecked by the wire schema; a known kind with a + // oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive. + if (result.shape !== 'paths') return null + // `paths` is likewise unchecked by the wire schema; a known shape with a // missing/malformed array would crash the paths card at `.map`. if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } } diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index 26eb16a7f9..922d6db848 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -38,8 +38,8 @@ const GREP_ARGS = '{"pattern":"foo","path":"src"}' const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}' /** A grep result view: matches grouped by file. */ -const resultMatches = (over?: Partial>): ToolResultView => ({ - card: 'search', kind: 'matches', +const resultMatches = (over?: Partial>): ToolResultView => ({ + card: 'search', shape: 'matches', files: [ { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, @@ -48,8 +48,8 @@ const resultMatches = (over?: Partial>): ToolResultView => ({ - card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, +const resultPaths = (over?: Partial>): ToolResultView => ({ + card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, }) const runningGrep = (over?: Partial): RunningToolCall => ({ @@ -90,7 +90,8 @@ describe('searchCardModel', () => { }) it('derives a paths card from the glob result view, carrying the truncation signal', () => { - expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ + // Empty block content isolates the truncation signal from the recovery arm. + expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ title: undefined, recovery: undefined, card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, @@ -118,53 +119,55 @@ describe('searchCardModel', () => { expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() }) - it('returns null for a card:search view whose kind this version does not compile', () => { - // `kind` rides the same untrusted wire frame as `card`; a subtype this client + it('returns null for a card:search view whose shape this version does not compile', () => { + // `shape` rides the same untrusted wire frame as `card`; a subtype this client // does not know must fall to the generic path, never render as a paths card // that would crash SearchBlock on an absent `paths`. - const futureKind = { - card: 'search', kind: 'future', truncated: false, total: 0, + const futureShape = { + card: 'search', shape: 'future', truncated: false, total: 0, } as unknown as ToolResultView - expect(searchCardModel(settledGrep({ resultView: futureKind }))).toBeNull() + expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull() }) - it('returns null for a known kind whose structured shape is missing or malformed', () => { - // The host wire schema checks the `card`/`kind` strings but not the grouped - // shape, so a version mismatch could deliver kind:'matches' with no `files` - // (or kind:'paths' with no `paths`). Rendering that crashes SearchBlock at + it('returns null for a known shape whose structured shape is missing or malformed', () => { + // The host wire schema checks the `card`/`shape` strings but not the grouped + // shape, so a version mismatch could deliver shape:'matches' with no `files` + // (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at // `.reduce`/`.map`; the derivation drops to the generic path instead. - const noFiles = { card: 'search', kind: 'matches', truncated: false, total: 0 } as unknown as ToolResultView + const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull() const badFile = { - card: 'search', kind: 'matches', truncated: false, total: 1, + card: 'search', shape: 'matches', truncated: false, total: 1, files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }], } as unknown as ToolResultView expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull() - const noPaths = { card: 'search', kind: 'paths', truncated: false, total: 0 } as unknown as ToolResultView + const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull() const badPaths = { - card: 'search', kind: 'paths', truncated: false, total: 1, paths: [42], + card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42], } as unknown as ToolResultView expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull() }) it('surfaces the recovery text only when the result was capped', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' - // Capped: the content (its `Full … stored at …` locator) rides through so the - // dropped rows stay reachable. + // The recovery locator lives in the raw tool/result content (the view carries + // no text), surfaced only when the card capped the result. const capped = searchCardModel(settledGrep({ - resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }), + content: [{ type: 'text', text: recovery }], + resultView: resultMatches({ truncated: true, total: 42 }), })) expect(capped?.recovery).toBe(recovery) - // Not capped: the card holds every match, so the content adds nothing and is - // dropped. + // Not capped: the card holds every match, so the raw content adds nothing and + // is dropped. const whole = searchCardModel(settledGrep({ - resultView: resultMatches({ truncated: false, content: [{ type: 'text', text: recovery }] }), + content: [{ type: 'text', text: recovery }], + resultView: resultMatches({ truncated: false }), })) expect(whole?.recovery).toBeUndefined() - // Capped but the presenter attached no content: nothing to surface. - const noContent = searchCardModel(settledGrep({ resultView: resultMatches({ truncated: true, total: 42 }) })) - expect(noContent?.recovery).toBeUndefined() + // Capped but the block carries no text: nothing to surface. + const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) })) + expect(noText?.recovery).toBeUndefined() }) }) @@ -205,7 +208,8 @@ describe('chat row search body (GenericToolCard fallback)', () => { it('the expanded body shows the recovery footer below a capped card', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' const view = render() fireEvent.click(view.container.querySelector('button')!) expect(searchKindOf(view.container)).toBe('matches') @@ -273,7 +277,8 @@ describe('SearchRow keyed card', () => { it('renders the recovery footer below the card when the search was capped', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' const view = render() expect(searchKindOf(view.container)).toBe('matches') expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() @@ -376,7 +381,7 @@ describe('DetailsPanel Output section (search)', () => { it('renders the recovery footer below the card for a capped search', () => { const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)' const view = mount(snapshot({ - nodes: [settledGlob({ resultView: resultPaths({ truncated: true, total: 23, content: [{ type: 'text', text: recovery }] }) })], + nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })], }), globTarget) expect(searchKindOf(view.container)).toBe('paths') expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy() From e1408c2a44adb07848b102405acc643d1ac8b0db Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 23:09:12 +0800 Subject: [PATCH 7/9] refactor(ui-primitives): extract shared head/tail cap and copy-feedback helpers The tail-header cap fix pushed SearchBlock's head/tail slicing arithmetic and its copy-feedback hook over the duplication gate's threshold against the byte-identical logic in TerminalBlock. Extract both into head-tail-cap.ts (headTailCap) and use-copy-feedback.ts (useCopyFeedback) and consume them from both blocks, deleting the clone rather than nudging it under the limit. --- .../client/ui-primitives/src/SearchBlock.tsx | 22 ++--------- .../ui-primitives/src/TerminalBlock.tsx | 25 +++---------- .../client/ui-primitives/src/head-tail-cap.ts | 33 +++++++++++++++++ .../ui-primitives/src/use-copy-feedback.ts | 37 +++++++++++++++++++ 4 files changed, 80 insertions(+), 37 deletions(-) create mode 100644 packages/client/ui-primitives/src/head-tail-cap.ts create mode 100644 packages/client/ui-primitives/src/use-copy-feedback.ts diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 5210b2fdc6..463a3bfa26 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -10,7 +10,8 @@ import { useCallback, useState, type ReactNode } from 'react' import clsx from 'clsx' -import { writeClipboard } from './clipboard.ts' +import { headTailCap } from './head-tail-cap.ts' +import { useCopyFeedback } from './use-copy-feedback.ts' import css from './SearchBlock.module.css' /** @@ -173,23 +174,13 @@ export function SearchBlock(props: SearchBlockProps) { const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props const [expanded, setExpanded] = useState(false) const [collapsed, setCollapsed] = useState>(() => new Set()) - const [copied, setCopied] = useState(false) // `props` is a fresh object each render, so memoizing on it never hits; the // flatten is cheap, so it runs inline keyed on the collapse set instead. const rows = toRows(props, collapsed) const shown = shownCount(props) const empty = rows.length === 0 - const text = copyText(props) - - const onCopy = useCallback(() => { - if (copied) return - void writeClipboard(text).then((ok) => { - if (!ok) return - setCopied(true) - window.setTimeout(() => { setCopied(false) }, 1000) - }) - }, [copied, text]) + const { copied, onCopy } = useCopyFeedback(copyText(props)) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) @@ -202,12 +193,7 @@ export function SearchBlock(props: SearchBlockProps) { }) }, []) - const hidden = rows.length - maxLines - const capped = hidden > 0 && !expanded - // Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed - // tool card), so a long result's head and tail slices agree across surfaces. - const headLines = Math.ceil(maxLines / 2) - const tailLines = maxLines - headLines + const { hidden, capped, headLines, tailLines } = headTailCap(rows.length, maxLines, expanded) const head = capped ? rows.slice(0, headLines) : rows const naturalTail = capped ? rows.slice(rows.length - tailLines) : [] // When the tail slice begins inside a file's matches, its own header sits diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index c707711f69..63fb554473 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -8,7 +8,8 @@ import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' import { parseAnsiLines, type AnsiLine } from './ansi.ts' -import { writeClipboard } from './clipboard.ts' +import { headTailCap } from './head-tail-cap.ts' +import { useCopyFeedback } from './use-copy-feedback.ts' import { Pill } from './Pill.tsx' import { StateDot, type StateDotState } from './StateDot.tsx' import css from './TerminalBlock.module.css' @@ -140,18 +141,9 @@ export function TerminalBlock({ return terminated ? parsed.slice(0, -1) : parsed }, [text]) const [expanded, setExpanded] = useState(false) - const [copied, setCopied] = useState(false) - - const onCopy = useCallback(() => { - if (copied) return - // The raw output, never the rendered tree: the prompt line and the status - // pill are chrome the user did not run. - void writeClipboard(text).then((ok) => { - if (!ok) return - setCopied(true) - window.setTimeout(() => { setCopied(false) }, 1000) - }) - }, [copied, text]) + // The raw output, never the rendered tree: the prompt line and the status pill + // are chrome the user did not run. + const { copied, onCopy } = useCopyFeedback(text) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) @@ -170,12 +162,7 @@ export function TerminalBlock({ // the raw text drew an output box of blank rows plus a copy control for // invisible bytes, and hid the placeholder that belongs there. const empty = lines.every(line => line.every(span => span.text.trim() === '')) - const hidden = lines.length - maxLines - const capped = hidden > 0 && !expanded - // Same split arithmetic as the TUI transcript's collapsed tool card, so a - // command's head and tail slices agree between the two front ends. - const headLines = Math.ceil(maxLines / 2) - const tailLines = maxLines - headLines + const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded) return (
diff --git a/packages/client/ui-primitives/src/head-tail-cap.ts b/packages/client/ui-primitives/src/head-tail-cap.ts new file mode 100644 index 0000000000..1ac540dd21 --- /dev/null +++ b/packages/client/ui-primitives/src/head-tail-cap.ts @@ -0,0 +1,33 @@ +// Head/tail height-cap arithmetic shared by the block primitives (TerminalBlock, +// SearchBlock) and matching the TUI transcript's collapsed tool card, so a long +// result's head and tail slices agree across every surface. The split is +// `ceil(maxLines / 2)` head rows and the remainder as tail rows; a result within +// the cap shows every row and hides none. + +/** The head/tail split metrics for a capped list. */ +export interface HeadTailCap { + /** Rows beyond the cap (list length − maxLines); ≤ 0 means nothing is hidden. */ + hidden: number + /** Whether the list is over the cap and not expanded, so it shows a head/tail slice. */ + capped: boolean + /** Head-slice row count: `ceil(maxLines / 2)`. */ + headLines: number + /** Tail-slice row count: the remainder after the head. */ + tailLines: number +} + +/** + * Compute the head/tail cap metrics for a list of `total` rows against `maxLines`, + * given whether the surface is expanded. Pure arithmetic; the caller slices its + * own rows with `headLines`/`tailLines` so a block can layer its own concerns + * (SearchBlock restores a tail file header) on top. + * @param total - the list's row count. + * @param maxLines - the collapsed-height cap in rows. + * @param expanded - whether the surface is expanded (uncaps the list). + * @returns the split metrics. + */ +export function headTailCap(total: number, maxLines: number, expanded: boolean): HeadTailCap { + const hidden = total - maxLines + const headLines = Math.ceil(maxLines / 2) + return { hidden, capped: hidden > 0 && !expanded, headLines, tailLines: maxLines - headLines } +} diff --git a/packages/client/ui-primitives/src/use-copy-feedback.ts b/packages/client/ui-primitives/src/use-copy-feedback.ts new file mode 100644 index 0000000000..1340a00625 --- /dev/null +++ b/packages/client/ui-primitives/src/use-copy-feedback.ts @@ -0,0 +1,37 @@ +// The copy-to-clipboard-with-feedback hook shared by the block primitives +// (TerminalBlock, SearchBlock): write the given text, and on success flip a +// transient `copied` flag that the caller renders as a "复制成功" label for one +// second. A refused write leaves the flag untouched, so the control never claims +// a copy the host declined. + +import { useCallback, useState } from 'react' +import { writeClipboard } from './clipboard.ts' + +/** How long the `copied` flag stays true after a successful write, in ms. */ +const COPIED_FEEDBACK_MS = 1000 + +/** The copy-feedback hook's return: the transient flag and the copy handler. */ +export interface CopyFeedback { + /** True for {@link COPIED_FEEDBACK_MS} after a successful write; render the success label off it. */ + copied: boolean + /** Copy the hook's text; no-op while `copied` is still true, silent on a refused write. */ + onCopy: () => void +} + +/** + * Copy `text` to the clipboard with one-second success feedback. + * @param text - the text to write on copy. + * @returns the `copied` flag and the `onCopy` handler. + */ +export function useCopyFeedback(text: string): CopyFeedback { + const [copied, setCopied] = useState(false) + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(text).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS) + }) + }, [copied, text]) + return { copied, onCopy } +} From 21cd24117726c67ac0a1a74259dac93fac581376 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 16:04:45 +0800 Subject: [PATCH 8/9] refactor(ui-conversation): extract shared toolview row-status helpers SearchRow (this PR) and FileMutationRow (landed on master) independently carry byte-identical rowStateStatus + rowResultText helpers, which the duplication gate flags once both are present. Extract both into contract/toolview-status.ts and consume them from both rows, deleting the clone rather than nudging it under the threshold. --- .../src/client/contract/toolview-status.ts | 45 +++++++++++++++++++ .../client/toolviews/file-mutation-row.tsx | 36 ++------------- .../src/client/toolviews/search-row.tsx | 43 +++--------------- 3 files changed, 53 insertions(+), 71 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/toolview-status.ts diff --git a/packages/client/ui-conversation/src/client/contract/toolview-status.ts b/packages/client/ui-conversation/src/client/contract/toolview-status.ts new file mode 100644 index 0000000000..043fe06522 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/toolview-status.ts @@ -0,0 +1,45 @@ +// Shared toolview-row helpers for the keyed rows whose card is resident below a +// summary (SearchRow, FileMutationRow): the visually hidden run-state label and +// the flattened settled-result text for the fallback arm a card cannot render. +// Both are pure functions of a frozen call slice — no chat-domain imports — so a +// row stays a thin ToolRowProps consumer. + +import type { ToolRowProps } from './slots.ts' +import type { ToolRowState } from './tool-call-model.ts' + +/** + * Visually hidden run-state label for a row's leading `StateDot` (which is + * `aria-hidden`), so assistive technology still announces the state. Returns + * null for the settled-ok state, which needs no spoken label. + * @param state - the row's run state. + * @returns the label, or null when none is needed. + */ +export function rowStateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * A settled result's text, flattened from its content blocks, for the fallback + * arm a keyed row shows when its card cannot render the result — an errored call + * (the tool emits no result view on error) or a settled call with no card view + * (a nested `run_code` sub-dispatch, a legacy generic result). The keyed row owns + * the render slot, so without this the model-facing text would have nowhere to + * go. Falls back to the error name/code when the result carries no text block. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +export function rowResultText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index 323a73e77c..c7dba58c5b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -18,6 +18,7 @@ import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client- import type { ToolRowProps } from '../contract/slots.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts' import css from './file-mutation-row.module.css' function leadingFor(state: ToolRowState) { @@ -29,37 +30,6 @@ function leadingFor(state: ToolRowState) { } } -/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ -function stateStatus(state: ToolRowState): string | null { - switch (state) { - case 'running': return '运行中' - case 'error': return '失败' - case 'stopped': return '已停止' - default: return null - } -} - -/** - * A settled result's text, flattened from its content blocks, for the arm that - * shows a failure the diff card cannot: write/edit return `undefined` from - * `presentResult` on `result.isError`, so an errored mutation has no diff card, - * and the keyed row is not a details-panel target. Without this the failure — - * an `old_string` that did not match, a permission denial — would read as a bare - * red dot with the model-facing error text nowhere on screen. - * @param block - the frozen call slice. - * @returns the result text, or null for a running call or an empty result. - */ -function errorText(block: ToolRowProps['block']): string | null { - if (!('kind' in block)) return null - const parts: string[] = [] - for (const item of block.content) { - if (item.type === 'text') parts.push(item.text) - } - if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) - const text = parts.join('\n') - return text === '' ? null : text -} - /** * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, * with the applied diff resident below it. The summary is a path link (a file @@ -70,11 +40,11 @@ function errorText(block: ToolRowProps['block']): string | null { export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { const model = toolRowModel(toolName, block, cwd) const diff = diffCardModel(block) - const status = stateStatus(model.state) + const status = rowStateStatus(model.state) const filePath = model.filePath // An errored mutation has no diff card (presentResult returns undefined on // isError); surface its result text so the failure is more than a red dot. - const failure = diff === null && model.state === 'error' ? errorText(block) : null + const failure = diff === null && model.state === 'error' ? rowResultText(block) : null return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 8c0181ba78..5ea72e4a25 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -17,6 +17,7 @@ import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-cli import type { ToolRowProps } from '../contract/slots.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts' import css from './search-row.module.css' /** Leading-slot glyph substitution: the search icon yields to the terminal @@ -30,40 +31,6 @@ function leadingFor(state: ToolRowState) { } } -/** Visually hidden status — StateDot is aria-hidden; assistive technology needs a text label. */ -function stateStatus(state: ToolRowState): string | null { - switch (state) { - case 'running': return '运行中' - case 'error': return '失败' - case 'stopped': return '已停止' - default: return null - } -} - -/** - * A settled result's text, flattened from its content blocks, for the arm that - * shows a result the search card cannot. Two cases reach it: an errored search - * (grep/glob emit no `presentResult` on an error result, so an errored search - * has no card), and a settled call whose result view is not a search card at all - * — a nested `run_code` sub-dispatch (the backend computes no presentationMeta - * for it, so `resultView` is null) or a legacy generic result. In both the keyed - * SearchRow owns the render slot, so without this arm the model-facing text would - * have nowhere to go: an errored search would read as a bare red dot, and a - * successful cardless result would show only its summary with its content lost. - * @param block - the frozen call slice. - * @returns the result text, or null for a running call or an empty result. - */ -function errorText(block: ToolRowProps['block']): string | null { - if (!('kind' in block)) return null - const parts: string[] = [] - for (const item of block.content) { - if (item.type === 'text') parts.push(item.text) - } - if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) - const text = parts.join('\n') - return text === '' ? null : text -} - /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the * completed search's card resident below it, and — when the result was capped — @@ -75,15 +42,15 @@ function errorText(block: ToolRowProps['block']): string | null { export function SearchRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) - const status = stateStatus(model.state) + const status = rowStateStatus(model.state) // A settled call with no search card — an errored search (grep/glob emit no // result view on error), a successful nested run_code sub-dispatch, or a // legacy generic result — has its model-facing text nowhere else to go, since // the keyed SearchRow owns this render slot. Surface it as the fallback body. - // A running call ('kind' absent) has no result to flatten; errorText returns - // null for it, so the arm stays closed until settle. + // A running call ('kind' absent) has no result to flatten; rowResultText + // returns null for it, so the arm stays closed until settle. const settled = 'kind' in block - const fallback = search === null && settled ? errorText(block) : null + const fallback = search === null && settled ? rowResultText(block) : null return (
From ce0aa90c1e1080fe3c85b9b91d02475b773c64ed Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 02:00:01 +0800 Subject: [PATCH 9/9] feat(cli): dsh --dump-config / --dump-default-config print the composed tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh --dump-config and dsh web --dump-config compose the shipped base, the surface overlay, and the --config or personal overlay — exactly the layers that surface boots — and print the entry list as YAML without booting; --dump-default-config stops at the surface overlay so the two outputs diff to precisely the user layer's effect. The dump shares the mounting code: the vendored include exports its patch algorithm as applyEntryPatches() and its !!js dialect as entryListSchema (logged in vendor/README.md), dsh-app-boot's renderConfigDump() composes and renders through both (and now imports the dialect instead of duplicating it), and the CLI adds a thin dump-config mode. !!js expressions print verbatim; unmatched patches warn on stderr; boot-only flags are rejected alongside the dump flags. (cherry picked from commit 1fdbebfa8a5dc7df840d53666320064a7e3dae59) --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- .../2026-07-30-dsh-dump-config.i18n.yaml | 6 + .../feature/2026-07-30-dsh-dump-config.md | 31 +++ .../feature/2026-07-30-dsh-dump-config.zh.md | 31 +++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/src/args.ts | 87 +++++++- apps/cli/src/bin.ts | 5 + apps/cli/src/dump-config.ts | 61 ++++++ apps/cli/tests/args.spec.ts | 23 +++ apps/cli/tests/built-bin.e2e.ts | 75 ++++++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 1 + packages/ui/app-boot/README.zh.md | 1 + packages/ui/app-boot/src/index.ts | 153 ++++++++++++-- .../ui/app-boot/tests/config-dump.spec.ts | 187 ++++++++++++++++++ vendor/README.md | 1 + vendor/include/src/index.ts | 172 +++++++++------- 21 files changed, 756 insertions(+), 106 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md create mode 100644 apps/cli/src/dump-config.ts create mode 100644 packages/ui/app-boot/tests/config-dump.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 11674d7747..9e8573a79d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 3331e36c86002d91fb272868268707fed014d01a -2026-07-20-dsh-cli-personal-config.zh.md: 172d84b075ed7ecc127c317b47e30581db67f89a +2026-07-20-dsh-cli-personal-config.md: 259c3865a9edcbc77949a9fe401af9a77e1e32c4 +2026-07-20-dsh-cli-personal-config.zh.md: 8f7c15c3c683cc855c6e3b704bfde8f87d4009d2 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 3331e36c86..259c3865a9 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -39,9 +39,9 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re ## Consequences - `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. -- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. -- `dsh-app-boot` depends on `js-yaml` (plus a load-only copy of the include's `!!js` YAML type) and, like `apps/cli`, on `@deepseek-ai/dsh-paths` for `resolveDshHome`. +- `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch). ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 172d84b075..8f7c15c3c6 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -39,9 +39,9 @@ PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录 ## Consequences - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 -- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;loader 的「配置项未找到/名称不匹配」警告是仅有的诊断。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 -- `dsh-app-boot` 依赖 `js-yaml`(外加一份只用于加载的 include `!!js` YAML 类型副本),并与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 +- `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。 ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml new file mode 100644 index 0000000000..0cd2549e55 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md +2026-07-30-dsh-dump-config.md: bc6504541c7868bad019a1bcd9f551435109e4c6 +2026-07-30-dsh-dump-config.zh.md: 5e173305a6cd03de3db4c763f26eeda6fba68ec7 diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md new file mode 100644 index 0000000000..bc6504541c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md @@ -0,0 +1,31 @@ +# Agent Note: dsh --dump-config prints the composed config tree + +Status: implemented + +English | [中文](2026-07-30-dsh-dump-config.zh.md) + +## Problem + +The booted tree is a composition the user never sees: the shipped base, a surface overlay, and the `--config` or personal `~/.dsh/config.yaml` overlay apply as sibling patch lists where each id-targeted patch replaces the row's whole `config` and an unmatched id only warns. Debugging a misbehaving personal overlay (a restated field dropped, a row id typo, a patch applying to the wrong surface) required mentally replaying the patch algorithm across three files. There was no way to see the effective tree or to diff it against the shipped defaults. + +## Decision + +`dsh --dump-config` and `dsh web --dump-config` print the composed entry list — base, surface overlay, then the `--config` or personal overlay, exactly the layers that surface's boot assembles — as YAML on stdout and exit without booting. `dsh --dump-default-config` / `dsh web --dump-default-config` stop at the surface overlay, so diffing the two outputs shows precisely what the user layer changes. + +The dump cannot drift from what boots because it shares the mounting code: the vendored include exports its patch algorithm as the pure `applyEntryPatches(data, patches, warn)` (the private `applyPatches` method now delegates to it) and its `!!js` YAML dialect as `entryListSchema`; `dsh-app-boot`'s `renderConfigDump()` composes labeled layers and renders through both, and `apps/cli/src/dump-config.ts` is a thin surface-selection wrapper. `!!js` expressions print verbatim and unevaluated — the dump shows composition, not one process's environment — and a patch whose target row is absent goes to stderr with its layer label, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, web CLI-flag patches, the frontend dist path) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) and each other, and `--dump-default-config` takes no `--config`. + +Each run of same-provenance rows is preceded by a `# ==` comment naming the file that contributed the rows and the layers that patched them (`# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows which section comes from which file while remaining one loadable YAML document. Composition is one flattened `applyEntryPatches` call over all layers — boot's exact call shape, so even patch-visibility corner cases (a later layer targeting a group child that a plain `config` replacement introduced, invisible to the single-pass id index) compose identically; applying one call per layer would rebuild the index between layers and print a tree boot never mounts. Provenance is derived from single-call prefix snapshots (base + layers 1..k) diffed positionally: the patch algorithm only rewrites rows in place or appends, so a top-level index identifies one row across snapshots, and a layer counts as having patched a row when adding it changed that row (config replacement, disable, group insert). Patch lists are cloned per snapshot because `applyEntryPatches` pushes `insert` rows by reference from the patch list. + +`dsh-app-boot` previously duplicated the include's `!!js` YAML type for patch parsing; it now imports `entryListSchema`, so the dialect has one owner. + +## Alternatives considered + +**Boot the tree and dump `ctx.loader.entries()`.** Rejected: booting evaluates `!!js` expressions (leaking one machine's environment into the printed config), starts adapters and sessions as side effects, requires a TTY-independent teardown path, and is slow. The dump is for debugging composition, which is a pure function of the files. + +**Reimplement the patch merge in the CLI.** Rejected: a second implementation of `applyPatches` would silently drift from the vendored include — the exact failure mode the feature exists to debug. Exporting the include's own algorithm costs one logged vendor modification and guarantees identity. + +**A `/dump-config` TUI command instead of flags.** Rejected as the only form: the primary use is a piped `dsh --dump-config | diff - <(dsh --dump-default-config)` style workflow, which needs a boot-free non-TTY surface. A TUI command can be added later over the same `renderConfigDump`. + +## Consequences + +Config debugging becomes one command instead of mental patch replay, and support can ask for `--dump-config` output. The vendored include carries one more logged local modification (the `applyEntryPatches`/`entryListSchema` exports; behavior-preserving for mounting) to re-apply on upstream sync. Provenance tracking re-composes one prefix snapshot per layer and diffs rows by JSON stringify, so the dump does extra work proportional to layers² × rows; that cost lives only in the boot-free dump path. `renderConfigDump` is unit-tested for layer ordering, verbatim `!!js` round-tripping, provenance separators and grouping, labeled unmatched-patch warnings, and loud read/parse/shape failures; the built-bin e2e drives all four flag forms through `lib/bin.js` including the personal-overlay layer, its provenance label, and its stderr warning. diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md new file mode 100644 index 0000000000..5e173305a6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md @@ -0,0 +1,31 @@ +# Agent Note: dsh --dump-config 打印合成后的配置树 + +Status: implemented + +[English](2026-07-30-dsh-dump-config.md) | 中文 + +## Problem + +启动的配置树是一份用户从未见过的合成结果:已交付的基础配置、界面覆盖层,以及 `--config` 或个人 `~/.dsh/config.yaml` 覆盖层作为同级补丁列表依次应用,其中每个按 id 定向的补丁替换目标行的整个 `config`,未匹配的 id 只产生警告。调试一个行为异常的个人覆盖层(漏掉需要重述的字段、行 id 拼错、补丁应用到了错误的界面)需要在脑中跨三个文件重放补丁算法。既没有办法看到生效的树,也没有办法把它与已交付的默认值做 diff。 + +## Decision + +`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的条目列表——基础配置、界面覆盖层、再叠 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西。`dsh --dump-default-config` / `dsh web --dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。 + +dump 不可能与实际启动漂移,因为它复用挂载代码:vendored include 把补丁算法导出为纯函数 `applyEntryPatches(data, patches, warn)`(私有的 `applyPatches` 方法现在委托给它),并把 `!!js` YAML 方言导出为 `entryListSchema`;`dsh-app-boot` 的 `renderConfigDump()` 通过这两者对带标签的层完成合成与渲染,`apps/cli/src/dump-config.ts` 只是选择界面的薄封装。`!!js` 表达式原样打印、不求值——dump 展示的是合成结果,不是某个进程的环境——目标行不存在的补丁会连同其层标签报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、web 的 CLI 标志补丁、前端 dist 路径)是每次调用的事实,位于配置树之外,不会出现。dump 标志拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)且两个 dump 标志互斥,`--dump-default-config` 不接受 `--config`。 + +每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献这些行的文件以及修补过它们的层(`# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示每一节来自哪个文件,又仍是一份可加载的 YAML 文档。合成是对所有层展平后的一次 `applyEntryPatches` 调用——与启动的调用形状完全一致,因此即便是补丁可见性的边角情况(后一层定位到前一层通过普通 `config` 替换引入的组内子项,而单遍 id 索引看不到它)也与启动合成完全相同;若按层各调用一次,会在层与层之间重建索引,打印出一棵启动从不挂载的树。来源从单次调用的前缀快照(基础 + 第 1..k 层)按位置 diff 得出:补丁算法只会原地改写行或在末尾追加,因此顶层索引在各快照之间标识同一行;加入某层后该行发生变化(替换 config、禁用、组内插入)即视为该层修补了这一行。每个快照都会克隆补丁列表,因为 `applyEntryPatches` 会把 `insert` 行按引用从补丁列表推入结果。 + +`dsh-app-boot` 之前为解析补丁复制了 include 的 `!!js` YAML 类型;现在改为导入 `entryListSchema`,方言只有一个归属者。 + +## Alternatives considered + +**启动整棵树后 dump `ctx.loader.entries()`。** 拒绝:启动会求值 `!!js` 表达式(把某台机器的环境泄漏进打印的配置)、以副作用启动适配器和会话、需要独立于 TTY 的拆卸路径,而且慢。dump 是用来调试合成的,而合成是那些文件的纯函数。 + +**在 CLI 里重新实现补丁合并。** 拒绝:`applyPatches` 的第二个实现会与 vendored include 悄然漂移——这恰恰是该功能要调试的失败模式。导出 include 自己的算法只花费一条记录在案的 vendor 修改,却保证了同一性。 + +**用 `/dump-config` TUI 命令代替标志。** 作为唯一形式被拒绝:主要用法是 `dsh --dump-config | diff - <(dsh --dump-default-config)` 这类管道工作流,需要免启动、非 TTY 的界面。之后可以在同一个 `renderConfigDump` 之上再加 TUI 命令。 + +## Consequences + +配置调试从脑中重放补丁变成一条命令,支持工作也可以直接索要 `--dump-config` 输出。vendored include 多出一条记录在案的本地修改(导出 `applyEntryPatches`/`entryListSchema`;对挂载行为无影响),上游同步时需重新应用。来源追踪为每层重新合成一次前缀快照并按 JSON stringify 对行做 diff,因此 dump 有与层数²×行数成正比的额外开销;该开销只存在于免启动的 dump 路径。`renderConfigDump` 的单元测试覆盖层叠顺序、`!!js` 原样往返、来源分隔与分组、带标签的未匹配补丁警告,以及读取/解析/形状失败的大声报错;built-bin e2e 通过 `lib/bin.js` 驱动全部四种标志形式,包括个人覆盖层、其来源标签及其 stderr 警告。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 1a6a21aac6..33fed58119 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: fc5bce195fb10872c605cada1bcb3ed79380265c -README.zh.md: 2fb1272231abb02e145e5c9925362de27307ca86 +README.md: cf038ad19c631721c7b3182ffe83e75e3837d9ba +README.zh.md: c790f973a9ab0071253ab161bd8bc7835ebb2e02 diff --git a/apps/cli/README.md b/apps/cli/README.md index fc5bce195f..cf038ad19c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,7 +3,7 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -17,6 +17,8 @@ The TUI surface: `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. +`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. + The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 2fb1272231..c790f973a9 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,7 +3,7 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -17,6 +17,8 @@ TUI 界面: `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 +`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 + Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index a1f0fff4c4..ac74b9468d 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -23,6 +23,22 @@ interface TuiInvocation { resume?: string } +/** + * Print the composed config tree and exit, without booting: `--dump-config` + * composes the shipped base, the surface overlay, and the `--config` or + * personal overlay — exactly the layers that surface would boot; + * `--dump-default-config` stops at the surface overlay (the shipped tree, no + * user layer). + */ +interface DumpConfigInvocation { + mode: 'dump-config' + surface: 'tui' | 'web' + /** Omit the `--config`/personal layer and print only the shipped composition. */ + defaultOnly: boolean + /** The `--config` overlay to compose instead of the personal one. */ + config?: string +} + /** Headless one-shot: `dsh -p "task"`. */ interface HeadlessInvocation { mode: 'headless' @@ -69,6 +85,7 @@ interface WebInvocation { /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ export type DshInvocation = | TuiInvocation + | DumpConfigInvocation | HeadlessInvocation | MetaInvocation | SkillSessionInvocation @@ -82,6 +99,34 @@ interface WebOptions { dev?: boolean workspaceRoot?: string trustedHost?: string[] + dumpConfig?: boolean + dumpDefaultConfig?: boolean +} + +/** + * Resolve the two dump flags for one surface, or return `undefined` when + * neither was passed. Both flags together are contradictory (one includes the + * user layer, the other excludes it) and fail loud through `error`. + */ +function resolveDump( + surface: 'tui' | 'web', + options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean }, + error: (message: string) => never, +): DumpConfigInvocation | undefined { + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + error('error: --dump-config and --dump-default-config are mutually exclusive') + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && options.config !== undefined) { + error('error: --dump-default-config prints the shipped tree and takes no --config') + } + return { + mode: 'dump-config', + surface, + defaultOnly, + ...options.config !== undefined && { config: options.config }, + } } /** @@ -135,7 +180,26 @@ Examples: .option('--resume ', 'continue a past session by id') .option('--config ', 'apply this overlay of loader patches instead of the personal one') .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { + .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit') + .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit') + .action((options: { + config?: string + configReplace?: string + prompt?: string + resume?: string + dumpConfig?: boolean + dumpDefaultConfig?: boolean + }) => { + const dump = resolveDump('tui', options, message => program.error(message)) + if (dump !== undefined) { + // The dump prints composition; a boot-only flag alongside it would be + // silently ignored, so reject the mix loud. + if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) { + program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace') + } + resolved = dump + return + } if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently @@ -168,10 +232,18 @@ Examples: // a leaked config/prompt/resume option is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + const parent = program.opts<{ + config?: string + configReplace?: string + prompt?: string + resume?: string + dumpConfig?: boolean + dumpDefaultConfig?: boolean + }>() if (parent.config !== undefined || parent.configReplace !== undefined - || parent.prompt !== undefined || parent.resume !== undefined) { - program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`) + || parent.prompt !== undefined || parent.resume !== undefined + || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { + program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`) } } @@ -198,8 +270,15 @@ Examples: .option('--dev', 'developer mode: hot-reload the browser client') .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit') + .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit') .action((options: WebOptions) => { rejectParentOptions('web') + const dump = resolveDump('web', options, message => program.error(message)) + if (dump !== undefined) { + resolved = dump + return + } resolved = resolveWeb(options) }) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 6f7ae77c76..aeaaf42692 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -43,6 +43,11 @@ switch (invocation.mode) { await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) break } + case 'dump-config': { + const { runDumpConfig } = await import('./dump-config.ts') + runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config) + break + } case 'meta': { const { runMeta } = await import('./tui.ts') await runMeta() diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts new file mode 100644 index 0000000000..39a87c2dc8 --- /dev/null +++ b/apps/cli/src/dump-config.ts @@ -0,0 +1,61 @@ +/** + * `dsh --dump-config` / `dsh web --dump-config` — print the composed config + * tree without booting: the shipped base, the surface overlay, and (unless + * `--dump-default-config`) the `--config` or personal overlay, composed + * through the include's own patch algorithm so the printed tree is exactly + * what that surface would mount. `!!js` expressions print verbatim, + * unevaluated — the dump shows composition, not one process's environment. + * Launcher-provided boot-context values (session identity, CLI-flag patches) + * are per-invocation facts outside the config tree and do not appear. + * @module @deepseek-ai/dsh/dump-config + */ + +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + loadOverlayPatches, + loadPersonalPatches, + PERSONAL_CONFIG_FILENAME, + renderConfigDump, + type ConfigDumpLayer, +} from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' + +const NAME = 'dsh' + +const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) +const SURFACE_OVERLAYS = { + tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)), + web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), +} as const + +/* v8 ignore start -- composition over the unit-tested renderConfigDump; the + built-bin e2e drives this path end to end */ +/** + * Print one surface's composed config tree to stdout, with a comment + * separator naming the file each section of rows comes from (and the layers + * that patched it). + * @param surface - which surface overlay to compose over the shared base. + * @param defaultOnly - stop at the surface overlay (no `--config`/personal layer). + * @param config - the `--config` overlay path composed instead of the personal + * one, or `undefined` to use `$DSH_HOME/config.yaml`. + */ +export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void { + const overlay = SURFACE_OVERLAYS[surface] + const layers: ConfigDumpLayer[] = [ + { label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) }, + ] + if (!defaultOnly) { + if (config === undefined) { + const personal = loadPersonalPatches(NAME) + // The personal file may be absent; the shipped layers still print. + if (personal !== undefined) { + layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) + } + } else { + layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) + } + } + process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers)) +} +/* v8 ignore stop */ diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 396a0e6ca5..cda7818e63 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -45,6 +45,29 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) + it('routes the dump flags per surface: composed with the user layer, or shipped only', () => { + expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false }) + expect(parse(['--dump-config', '--config', 'c.yml'])) + .toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' }) + expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true }) + expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false }) + expect(parse(['web', '--dump-config', '--config', 'w.yml'])) + .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' }) + expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true }) + // The two dump flags contradict each other; boot-only flags alongside a + // dump would be silently ignored; the shipped tree takes no user overlay. + expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1) + expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1) + expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1) + expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) + expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) + // A leaked dump flag on a subcommand that has none is a mistyped invocation. + expect(exitCode(['meta', '--dump-config'])).toBe(1) + expect(exitCode(['upgrade', '--dump-config'])).toBe(1) + }) + it('exits nonzero instead of silently starting fresh or dropping inputs', () => { // Empty resume/prompt would be swallowed downstream; --prompt mixed with // TUI inputs must not lose them. (Bad host/port are gated by the webserver diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index d5bcbfd378..c9e1b29969 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,8 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -22,13 +23,20 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ -async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin], { +/** + * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + * + exit code. `env` isolates the Harness home for surfaces that read it. + */ +async function runBuiltBin( + args: readonly string[] = [], + env: Record = {}, +): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, + env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -45,4 +53,61 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) + + describe('dsh --dump-config', () => { + let home: string + beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) + afterEach(() => { rmSync(home, { recursive: true, force: true }) }) + + it('prints the shipped TUI composition without booting or needing a TTY', async () => { + const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stderr).toBe('') + // Base rows composed with the TUI overlay's surface values, `!!js` + // expressions verbatim (unevaluated), and TUI-only inserted rows present. + expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") + expect(stdout).toContain('model: deepseek-v4-pro') + expect(stdout).toContain('cwd: !!js process.cwd()') + expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'") + // Provenance comment separators name each section's source file. + expect(stdout).toContain('# == base.cordis.yml') + expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml') + expect(stdout).toContain('# == tui.cordis.yml') + }, 30_000) + + it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => { + writeFileSync(join(home, 'config.yaml'), [ + '- id: agent-loop', + ' config:', + ' agents:', + ' - id: main', + ' provider: custom-provider', + ' model: custom-model', + '- id: only-on-web', + ' config:', + ' value: 1', + '', + ].join('\n')) + const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout).toContain('provider: custom-provider') + expect(stdout).not.toContain('model: deepseek-v4-pro') + // The personal layer appears in the patched row's provenance and the + // skipped-patch warning carries its label. + expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`) + expect(stderr).toContain('patch: entry "only-on-web" not found') + + // The shipped view ignores the personal overlay entirely. + const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) + expect(shipped.stdout).not.toContain('custom-provider') + expect(shipped.stdout).toContain('model: deepseek-v4-pro') + }, 30_000) + + it('composes the web overlay for `dsh web --dump-config`', async () => { + const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") + expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'") + }, 30_000) + }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 684f322477..619a3ab81d 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3 -README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42 +README.md: 51bc5082512632dd493956b96c605aff47dc872e +README.zh.md: 644d3a3613a9516cb02881fac8ba7531bffa81eb diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 54f754842d..51bc508251 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -14,6 +14,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index dd56084812..644d3a3613 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -14,6 +14,7 @@ | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 015898f2cb..58b9d63bf8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -12,7 +12,7 @@ import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-paths' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -60,16 +60,12 @@ export function loadEnv( /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' -// The include's YAML dialect: `!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time. Personal -// patches are parsed with the same schema so they may reference `process.env`. -// Load-only: this schema never dumps, so no `predicate`/`represent`. -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: data => ({ __jsExpr: String(data) }), -}) -const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType) +// The include's YAML dialect (`!!js` scalars become expression nodes the +// Loader interpolates against each entry's context at mount time), imported +// from the include itself so patch parsing and config dumping can never drift +// from what the include mounts. Personal patches share it so they may +// reference `process.env`. +const personalPatchesSchema = entryListSchema /** * Load the optional personal overlay patches (`config.yaml` under the Harness @@ -149,6 +145,141 @@ function parsePatchList( return parsed as PatchOptions[] } +/** One overlay patch list with the label provenance comments print for it. */ +export interface ConfigDumpLayer { + /** Source name shown in provenance comments (a file basename or path). */ + label: string + /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + patches: PatchOptions[] +} + +/** + * Compose the effective entry list exactly as `boot()` would mount it: parse + * the base config file with the include's entry-list dialect, apply every + * layer's patches as ONE flattened list through the include's own patch + * algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so + * even patch-visibility corner cases (a later layer targeting a group child a + * plain config replacement introduced, which the single-pass id index never + * sees) compose identically — then render the result as YAML in the same + * dialect (`!!js` expressions print verbatim, unevaluated). + * + * Every run of rows with the same provenance is preceded by a `# ==` comment + * naming the file that contributed the rows and any layers that patched them, + * so the output stays a loadable YAML document while showing which section + * comes from which file. Provenance is derived from single-call prefix + * snapshots (base + layers 1..k), diffed positionally: the patch algorithm + * only rewrites rows in place or appends, so a top-level index identifies one + * row across snapshots, and a layer whose addition changes the row (config + * replacement, disable, group insert) is listed as having patched it. + * + * A patch that matches no row is reported through `warn` with its layer + * label, mirroring the Loader's boot-time warning. Earlier layers' patches + * see an identical preceding state in every snapshot that includes them, so + * each snapshot's warning list extends the previous one and the new tail + * belongs to the added layer. + * @param binName - the diagnostic prefix on read/parse errors. + * @param absoluteConfigPath - the base config file `boot()` would include. + * @param layers - overlay layers in application order (later wins). + * @param warn - sink for skipped-patch diagnostics; defaults to stderr. + * @returns the composed entry list rendered as a YAML document with + * provenance comment separators. + */ +export function renderConfigDump( + binName: string, + absoluteConfigPath: string, + layers: ConfigDumpLayer[], + warn: (line: string) => void = line => void process.stderr.write(`${line}\n`), +): string { + let content: string + try { + content = readFileSync(absoluteConfigPath, 'utf8') + } catch (error) { + throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`) + } + let parsed: unknown + try { + parsed = yaml.load(content, { schema: entryListSchema }) + } catch (error) { + throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`) + } + if (!Array.isArray(parsed)) { + throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`) + } + const baseLabel = basename(absoluteConfigPath) + // The YAML boundary yields untyped rows; the include validates entry shape + // at mount, and the dump prints whatever the file holds, so `EntryOptions` + // here is structural trust in the same file `boot()` would include. + const base = parsed as Parameters[0] + // snapshot_k = ONE application of layers 1..k flattened — boot's exact call + // shape for that prefix. snapshot_N is therefore the mounted composition. + // The patches are cloned per call: applyEntryPatches detaches the entry + // list but pushes `insert` rows by reference from the patch list, so + // sharing patch objects across snapshot calls would leak a later + // snapshot's mutations into an earlier one's result. + const snapshot = (count: number, warnings: string[]): ReturnType => { + const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) + return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { + // The include logs through cordis's printf-style logger (`%C` = code); a + // dump has no logger, so substitute inline for a plain line. + let index = 0 + warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++]))) + }) + } + let previous = base + let previousWarnings: string[] = [] + const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] })) + let composed = base + for (let count = 1; count <= layers.length; count += 1) { + const layer = layers[count - 1] + /* v8 ignore next -- count iterates 1..length, so the slot exists */ + if (layer === undefined) continue + const warnings: string[] = [] + composed = snapshot(count, warnings) + for (const line of warnings.slice(previousWarnings.length)) { + warn(`${binName}: [${layer.label}] ${line}`) + } + const before = previous.map(entry => JSON.stringify(entry)) + for (let index = 0; index < composed.length; index += 1) { + if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] }) + else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label) + } + previous = composed + previousWarnings = warnings + } + return groupedDump(composed, provenance) +} + +/** Render the composed rows grouped under one provenance comment per contiguous run. */ +function groupedDump( + composed: readonly unknown[], + provenance: readonly { origin: string; patchedBy: string[] }[], +): string { + const lines: string[] = [] + let currentLabel: string | undefined + let group: unknown[] = [] + const flush = (): void => { + if (currentLabel === undefined || group.length === 0) return + lines.push(`# == ${currentLabel}`) + lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd()) + group = [] + } + for (let index = 0; index < composed.length; index += 1) { + const record = provenance[index] + /* v8 ignore next -- provenance is index-aligned with composed by construction */ + if (record === undefined) continue + const label = record.patchedBy.length === 0 + ? record.origin + : `${record.origin}, patched by ${record.patchedBy.join(', ')}` + if (label !== currentLabel) { + flush() + currentLabel = label + } + group.push(composed[index]) + } + flush() + return lines.join('\n') + '\n' +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/ui/app-boot/tests/config-dump.spec.ts b/packages/ui/app-boot/tests/config-dump.spec.ts new file mode 100644 index 0000000000..99af81f2c2 --- /dev/null +++ b/packages/ui/app-boot/tests/config-dump.spec.ts @@ -0,0 +1,187 @@ +/** + * `renderConfigDump` behavior: the offline composition must equal what + * `boot()` mounts (same parser, same patch algorithm), print `!!js` + * expressions verbatim, separate provenance runs with comment lines while + * staying one loadable YAML document, and report skipped patches through + * `warn` instead of failing — mirroring the Loader's boot-time warning for a + * shared overlay whose row exists only on another surface. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@cordisjs/plugin-include' +import { loadOverlayPatches, renderConfigDump } from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-')) + +function writeBase(dir: string): string { + const base = join(dir, 'base.yml') + writeFileSync(base, [ + '- id: shared', + ' name: ./noop.mjs', + ' config:', + ' value: base', + ' key: !!js process.env.DSH_DUMP_SPEC', + '- id: untouched', + ' name: ./noop.mjs', + '', + ].join('\n')) + return base +} + +describe('renderConfigDump', () => { + it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => { + const dir = tmp() + const base = writeBase(dir) + const surface = join(dir, 'surface.yml') + writeFileSync(surface, [ + '- id: shared', + ' config:', + ' value: surface', + ' key: !!js process.env.DSH_DUMP_SPEC', + '- insert:', + ' - id: surface-extra', + ' name: ./noop.mjs', + '', + ].join('\n')) + const personal = join(dir, 'personal.yml') + writeFileSync(personal, [ + '- id: surface-extra', + ' config:', + ' value: personal', + '', + ].join('\n')) + + const dump = renderConfigDump(NAME, base, [ + { label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) }, + { label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) }, + ], () => {}) + // Comments do not break loadability: the dump parses as one document + // equal to what boot() would mount. + const parsed = yaml.load(dump, { schema: entryListSchema }) as { + id: string + config?: Record + }[] + expect(parsed).toEqual([ + { + id: 'shared', + name: './noop.mjs', + config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } }, + }, + { id: 'untouched', name: './noop.mjs' }, + { id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } }, + ]) + // Unevaluated: the expression text round-trips as a !!js scalar. + expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC') + // Provenance separators: origin file, plus every layer that changed the + // row; an inserted row carries the inserting layer as its origin. + expect(dump).toContain('# == base.yml, patched by surface.yml') + expect(dump).toContain('# == base.yml\n- id: untouched') + expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra') + expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched')) + }) + + it('groups contiguous same-provenance rows under one separator', () => { + const dir = tmp() + const base = join(dir, 'base.yml') + writeFileSync(base, [ + '- id: a', + ' name: ./noop.mjs', + '- id: b', + ' name: ./noop.mjs', + '', + ].join('\n')) + const dump = renderConfigDump(NAME, base, [], () => {}) + expect(dump.match(/# == base\.yml/g)).toHaveLength(1) + expect(dump).toContain('# == base.yml\n- id: a') + }) + + it('composes all layers as one flattened patch list, exactly like boot()', () => { + // boot() flattens every layer into ONE applyEntryPatches call, whose id + // index sees inserted rows but NOT children introduced by a plain group + // `config` replacement. A per-layer composition would rebuild the index + // between layers and let the second layer patch that child — a tree the + // real boot never mounts. Pin the single-call semantics: the child patch + // is skipped (with the layer-labeled warning), matching boot. + const dir = tmp() + const base = join(dir, 'base.yml') + writeFileSync(base, [ + '- id: g', + ' name: ./group.mjs', + ' group: true', + ' config: []', + '', + ].join('\n')) + const warnings: string[] = [] + const dump = renderConfigDump(NAME, base, [ + { + label: 'a.yml', + patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }], + }, + { label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] }, + ], line => void warnings.push(line)) + expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`]) + const parsed = yaml.load(dump, { schema: entryListSchema }) as { + config?: { config?: { v?: number } }[] + }[] + expect(parsed[0]?.config?.[0]?.config?.v).toBe(1) + // The skipped layer did not change the row, so it is not in provenance. + expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g') + expect(dump).not.toContain('b.yml\n- id: g') + }) + + it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => { + const dir = tmp() + const base = writeBase(dir) + const overlay = join(dir, 'overlay.yml') + writeFileSync(overlay, [ + '- id: only-on-another-surface', + ' config:', + ' value: ignored', + '- id: shared', + ' config:', + ' value: patched', + '', + ].join('\n')) + const warnings: string[] = [] + const dump = renderConfigDump( + NAME, base, + [{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }], + line => void warnings.push(line), + ) + expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`]) + const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[] + expect(parsed[0]?.config?.value).toBe('patched') + }) + + it('defaults its warn sink to one stderr line per skipped patch', () => { + const dir = tmp() + const base = writeBase(dir) + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }]) + expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`) + } finally { + write.mockRestore() + } + }) + + it('fails loud on a missing, unparsable, or non-array base config', () => { + const dir = tmp() + expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {})) + .toThrow(new RegExp(`^${NAME}: failed to read config `)) + const invalid = join(dir, 'invalid.yml') + writeFileSync(invalid, 'invalid: [unclosed\n') + expect(() => renderConfigDump(NAME, invalid, [], () => {})) + .toThrow(new RegExp(`^${NAME}: failed to parse config `)) + const scalar = join(dir, 'scalar.yml') + writeFileSync(scalar, 'id: not-a-list\n') + expect(() => renderConfigDump(NAME, scalar, [], () => {})) + .toThrow('must be a top-level YAML array of entries') + }) +}) diff --git a/vendor/README.md b/vendor/README.md index b140c057ab..2d3e1b6b05 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,6 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 29f6a3a951..29c894401c 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -13,7 +13,15 @@ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', { represent: (data) => data['__jsExpr'], }) -const schema = yaml.JSON_SCHEMA.extend(JsExpr) +/** + * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes + * the Loader evaluates at entry activation. Exported so config tooling + * (`dsh --dump-config`) parses and prints exactly the dialect this include + * mounts. + */ +export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr) + +const schema = entryListSchema const writable: Record = { '.json': 'application/json', @@ -23,6 +31,92 @@ const writable: Record = { const supported = new Set(Object.keys(writable)) +/** + * Apply patch lists to an entry list — THE patch semantics of this include, + * shared by mounting (`applyPatches`) and offline config tooling + * (`dsh --dump-config`) so a dump can never drift from what boots. The input + * is never mutated: patching shared entry objects would bake earlier patch + * values into the cached parse, so repeated application (config hot-reloads) + * could never revert a removed or changed patch. Inserted entries are indexed + * as they are added, so a later patch in the same list can target a row an + * earlier patch inserted. A patch that matches nothing warns and is skipped. + * @param data - the parsed entry list (JSON-safe plain data). + * @param patches - the patch list to apply, in order. + * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code). + * @returns a detached entry list with every applicable patch applied. + */ +export function applyEntryPatches( + data: EntryOptions[], + patches: PatchOptions[] | undefined, + warn: (message: string, ...args: any[]) => void, +): EntryOptions[] { + if (!patches?.length) return [...data] + data = structuredClone(data) + + const entryMap = new Map() + const buildMap = (entries: EntryOptions[]) => { + for (const entry of entries) { + if (entry.id) entryMap.set(entry.id, entry) + if (entry.group && Array.isArray(entry.config)) { + buildMap(entry.config) + } + } + } + buildMap(data) + + for (const patch of patches) { + const { id, insert, name, ...overrides } = patch + + if (insert) { + if (id) { + const target = entryMap.get(id) + if (!target) { + warn('patch insert: entry %C not found', id) + continue + } + if (!target.group) { + warn('patch insert: entry %C is not a group', id) + continue + } + if (!Array.isArray(target.config)) target.config = [] + target.config.push(...insert) + } else { + data.push(...insert) + } + // Index what this patch added so a LATER patch in the same list can + // target it. Patch lists compose one layer per source (surface overlay, + // then `--config`, then the user's), and a layer must be able to + // configure or disable a row an earlier layer inserted; without this, + // inserted rows were silently unpatchable. + buildMap(insert) + continue + } + + if (!id) { + warn('patch: id is required for non-insert patches') + continue + } + + const target = entryMap.get(id) + if (!target) { + warn('patch: entry %C not found', id) + continue + } + + if (name && name !== target.name) { + warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name) + continue + } + + for (const [key, value] of Object.entries(overrides)) { + if (key === 'id') continue + target[key] = value + } + } + + return data +} + /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -125,79 +219,9 @@ export class Include extends EntryTree { } private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { - // Always detach from the cached parse: patching shared entry objects would - // bake earlier patch values into `this.data`, so repeated application - // (config hot-reloads) could never revert a removed or changed patch. The - // supported extensions guarantee JSON-safe plain data, so `structuredClone` - // cannot throw here. - if (!patches?.length) return [...data] - data = structuredClone(data) - - const entryMap = new Map() - const buildMap = (entries: EntryOptions[]) => { - for (const entry of entries) { - if (entry.id) entryMap.set(entry.id, entry) - if (entry.group && Array.isArray(entry.config)) { - buildMap(entry.config) - } - } - } - buildMap(data) - - for (const patch of patches) { - const { id, insert, name, ...overrides } = patch - - if (insert) { - if (id) { - const target = entryMap.get(id) - if (!target) { - this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id) - continue - } - if (!target.group) { - this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id) - continue - } - if (!Array.isArray(target.config)) target.config = [] - target.config.push(...insert) - } else { - data.push(...insert) - } - // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (surface overlay, - // then `--config`, then the user's), and a layer must be able to - // configure or disable a row an earlier layer inserted; without this, - // inserted rows were silently unpatchable. - buildMap(insert) - continue - } - - if (!id) { - this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches') - continue - } - - const target = entryMap.get(id) - if (!target) { - this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id) - continue - } - - if (name && name !== target.name) { - this.ctx.root.logger?.('loader').warn( - 'patch: name mismatch for %C (expected %C, got %C), skipping', - id, target.name, name, - ) - continue - } - - for (const [key, value] of Object.entries(overrides)) { - if (key === 'id') continue - target[key] = value - } - } - - return data + return applyEntryPatches(data, patches, (message, ...args) => { + this.ctx.root.logger?.('loader').warn(message, ...args) + }) } async* [Service.init]() {