DSH / Atlas
2026-07-30implementedfeature

Web search card — the grep and glob render intent reaches the browser

Web 搜索卡片 —— grep 与 glob 的 render intent 到达浏览器

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 r

English

Problem

The grep and glob tools declare a result-time card: 'search' render intent (search render card): 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 note owns the backend contract and its two producers; this note owns 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-tool/src/client/tool/models/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.

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, 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: <locator> 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.

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-row.tsx) registers ONE component under both grep and glob in the tool.call.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 terminal/diff cards; the unified expand-and-inspect note owns the whole-row collapse/expand interaction that flipped all resident cards at once.)
  • The generic fallback (chat/GenericToolCardchat/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.

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 folded capped-result summary, 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.client.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-tool/tests/search-card.client.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-tool/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

中文

Problem

grepglob 工具声明了一个仅在结果阶段存在的 card: 'search' render intent(search render card):SearchMatchesResultViewshape: 'matches')携带 grep 按文件分组的匹配,或 SearchPathsResultViewshape: 'paths')携带 glob 的扁平路径列表,两者都带 truncated/total 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 resultView 投递到 ConversationSnapshot 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。

这正是 search render card note 指名的后续:后端约定和它的两个生产者归那篇 note 所有,web 消费方归本 note 所有。

Decision

SearchBlock 是一个 ui-primitives 组件,把一次已完成的搜索渲染成两种形态之一,grep/glob 调用的 Web 渲染点都通过它消费搜索 render intent。ui-tool/src/client/tool/models/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 同时读 callViewresultView,因为命令、cwd、description 在调用时就存在;searchCardModel 只读 resultView,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。

一个组件绘制两种形态,用 kind 区分,因为 grepglob 是同一个视觉对象 —— 一个搜索结果。SearchMatchesBlockPropskind: 'matches')和 SearchPathsBlockPropskind: 'paths')让每种形态的字段保持必填,而不是所有字段都可选的单一接口。组件把它持有的形态压平成一个渲染行列表 —— matches 形态是一个文件头行加它的匹配行,paths 形态是每个路径一行 —— 于是高度上限把一个文件头当作一行来计,与一条匹配行或一个路径相同,头/尾切片算术就是 TerminalBlock 的(ceil(max/2) 头,其余为尾),因此一个长搜索结果和一段长命令输出在两张卡片间在同一处截断。

组件约定:

  • 按文件分组的匹配,逐文件可折叠。 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 lineNumber: line 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。
  • 扁平路径列表。 paths 形态每行一个路径,无头行。
  • 截断指示。 truncated 时,横幅摘要把截断前总数折入 —— grep 为 显示 X / 共 N 处匹配 · K 个文件,glob 为 显示 X / 共 N 个路径 —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 truncated 时摘要是一个朴素的结构计数({n} 处匹配 · {m} 个文件,或 {n} 个路径)。
  • 被截断结果的恢复脚注。 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 Full … stored at: <locator> 脚注 —— 只存在于原始 tool/result 内容里(搜索视图不携带结果文本;没有卡片的 UI 回退到那段原始内容),而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,searchCardModel 在(且仅在)结果被截断时把 block 自身压平后的结果文本作为 SearchCardModel.recovery 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其原始文本不增加任何信息,因此被丢弃。
  • 不软换行。 结果行在一个横向滚动的盒子里 white-space: pre,因此一条长匹配行或一个深路径横向滚动而不折叠。
  • 带展开控件的高度上限。 超过 DEFAULT_SEARCH_MAX_LINES(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 TerminalBlock 相同。
  • 复制。 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。

几何、圆角、字体镜像 CodeBlockTerminalBlock,因此搜索卡片与它们读作同一族;white-space: pre 加横向滚动是它们共享的刻意分歧。

渲染点

三个渲染点消费该推导,与终端卡片的落位完全一致:

  • keyed SearchRowtoolviews/search-row.tsx)把一个组件同时注册到 tool.call.toolview keyed hole 的 grepglob 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 CHAT_SEARCH_MAX_LINES(8)—— 与 BashRow 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 kind 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 run_code 子派发(后端不为其计算 presentationMeta,故 resultView 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 search === null && 已结算,而非仅凭错误状态。(该常驻姿态与 terminal/diff 卡片一致;一次性翻转了所有常驻卡片的整行折叠/展开交互归统一展开与检视 note所有。)
  • generic fallbackchat/GenericToolCardchat/ToolRow)把推导出的 model 作为展开门控的 body 传入,与 terminal 用的是同一分支:没有 keyed 行的 grep/glob 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。
  • details panelskeleton/DetailsPanel)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。

CHAT_SEARCH_MAX_LINES(8)是行内上限,为 primitive 默认值的一半(panel 保留默认值),理由与 CHAT_TERMINAL_MAX_LINES 相同:chat 流是跨多次调用扫读的摘要表面,panel 是单次调用的阅读表面。

Alternatives considered

两个卡片组件,每个工具一个。 否决:grepglob 是仅由 kind 区分的同一视觉对象,两个组件会重复横幅、高度上限、复制控件与不换行几何。一个按 kind 分支的组件正是后端那个单一 card: 'search' 视图的用途。

加一个 SearchCallView,让行在搜索运行时就渲染卡片。 否决:后端约定刻意没有调用阶段的搜索视图 —— 搜索在 execute 前没有匹配或路径。运行中的行只显示摘要,searchCardModel 对运行块返回 null,忠实于实际存在的东西。

复用 TerminalBlockCodeBlock 否决:两者都不建模逐文件可折叠的组或折叠式截断摘要,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。

Consequences

SearchBlock 只读搜索视图的字段,因此保持为 render intent 所携内容的纯函数 —— 无会话查询,与产生该视图的 presenter 一样可重放。没有搜索能力的 UI 仍得到 bridge 的围栏回退;工具的结果形态没有任何改变。给 ToolRow 扩一个 search body prop 只在 terminal 旁加一个分支;一次调用至多携带一种卡片,因此两者绝不同时出现在一行。

Testing

packages/client/ui-primitives/tests/search-block.client.spec.tsx 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与匹配行一样,在高度上限中单独计为一行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。

packages/client/ui-tool/tests/search-card.client.spec.tsx 固定每个渲染点的接线:searchCardModel 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 kind、以及一个形态缺失/错误的已知 kind);通过 GenericToolCard 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;SearchRow 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 grepglob 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。packages/client/ui-tool/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