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 a0b66d0f87..9edc74a0d2 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: 38d8b2f10b5b5b4f9b1d5c43a726877159737440 -2026-07-30-web-search-card.zh.md: 1d1f371d2fa846219ea8cc434727b5354508b454 +2026-07-30-web-search-card.md: 1dff5ae5a4d789b1e57fcaef349959764583fbdf +2026-07-30-web-search-card.zh.md: 09d38066bf16923655b27a30c717d97ccbe434bb 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 38d8b2f10b..1dff5ae5a4 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 @@ -22,7 +22,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`, 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} 个路径`). +- **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} 个路径`). - **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,7 +33,7 @@ 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-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 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. @@ -45,7 +45,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac **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. +**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 @@ -53,7 +53,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac ## 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-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. 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 1d1f371d2f..09d38066bf 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 @@ -22,7 +22,7 @@ Status: implemented - **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 - **扁平路径列表。** paths 形态每行一个路径,无头行。 -- **截断指示。** `truncated` 时,横幅摘要旁一个 pill 显示 `已截断 · 共 {total}`,因此卡片绝不把一个被截断的页面呈现为完整结果 —— 想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。横幅摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果;想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 - **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 - **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 - **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 @@ -33,7 +33,7 @@ Status: implemented 三个渲染点消费该推导,与终端卡片的落位完全一致: -- **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 范围内。) +- **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 段。 @@ -45,7 +45,7 @@ Status: implemented **加一个 `SearchCallView`,让行在搜索运行时就渲染卡片。** 否决:后端契约刻意没有调用阶段的搜索视图 —— 搜索在 `execute` 前没有匹配或路径。运行中的行只显示摘要,`searchCardModel` 对运行块返回 null,忠实于实际存在的东西。 -**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或截断 pill,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。 +**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或折叠式截断摘要,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。 ## Consequences @@ -53,7 +53,7 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、带 pre-cap total 的截断 pill、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 +`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` 服务。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 6941be9dff..64c4229e65 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -137,7 +137,7 @@ const TERMINAL_EXIT_STATUS: Record [ - file.path, - ...file.matches.map(m => ` Line ${m.lineNumber}: ${m.line}`), - ]), + 'Found 5 of 42 matches', '', - '(已显示 5 处匹配中的前 5 处,共 42 处;其余见溢出文件)', + ...SEARCH_MATCHES_FIXTURE.map(file => + [file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')), + '', + '(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)', ].join('\n') /** - * Structured glob result for the search sample (turn 68): a flat path list, + * Structured glob result for the search sample (turn 67): 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', + 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx', + 'packages/client/ui-conversation/src/client/toolviews/search-row.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') +/** + * The model-facing glob render text — the newline-joined path list plus a + * spill-recovery footer, mirroring the real glob presenter's shape (see + * formatGlobOutput in dsh-tool-fs-search). + */ +const SEARCH_PATHS_TEXT = [ + ...SEARCH_PATHS_FIXTURE, + '', + '(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)', +].join('\n') const DEEPSEEK_REASONING = { efforts: [ diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index aa88657e1d..1cd5de03a1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -20,7 +20,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 { searchToolview } from './toolviews/search-row.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { askQuestionToolview } from './toolviews/ask-question-row.tsx' 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 8373dc2ddd..9bb65a3092 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 @@ -65,8 +65,10 @@ export interface SearchCardModel { * 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). + * the compiled variants, a `card: 'search'` view whose `kind` 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). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the search-card props, or null for the generic path. */ @@ -76,10 +78,15 @@ 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 } - return { - title: result.title, - card: result.kind === 'matches' - ? { kind: 'matches', files: result.files, ...common } - : { kind: 'paths', paths: result.paths, ...common }, + if (result.kind === 'matches') { + return { title: result.title, 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 + // client does not compile. Guard the paths shape explicitly: an unknown kind + // 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 + return { title: result.title, 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 143174fe42..cb0c301c1b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -101,8 +101,9 @@ font: var(--dsw-font-xs-13); } -/* The terminal card sits directly under its section label, so it drops the - primitive's standalone vertical margin; the section owns the spacing. */ +/* A render-intent card (terminal or search) sits directly under its section + label, so it drops the primitive's standalone vertical margin; the section + owns the spacing. */ .terminal { margin: 0; } diff --git a/packages/client/ui-conversation/src/client/toolviews/search-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/toolviews/search-sample.module.css rename to packages/client/ui-conversation/src/client/toolviews/search-row.module.css diff --git a/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx similarity index 98% rename from packages/client/ui-conversation/src/client/toolviews/search-sample.tsx rename to packages/client/ui-conversation/src/client/toolviews/search-row.tsx index d717132d72..90ec5e3470 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -17,7 +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 css from './search-sample.module.css' +import css from './search-row.module.css' /** Leading-slot glyph substitution: the search icon yields to the terminal * state semantic (error = red, interrupted = amber). Running keeps the icon — diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index c728b5b1a3..c2ff38755c 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -18,7 +18,7 @@ import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/s 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' +import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx' afterEach(cleanup) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..ac680a2aae 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: c237e63074cc2e62e41d59e93fab3c020151c720 +README.zh.md: 432b5599bda06b0a9799870ae689a3a6834a3f88 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..c237e63074 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and SearchBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Search results + +`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..432b5599bd 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock,以及 SearchBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,10 @@ `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +## 搜索结果 + +`SearchBlock` 渲染一次已完成的搜索,一个组件绘制两种 kind(由 `kind` 判别)。`matches`(grep)把每个文件渲染为粗体路径头加其 `lineNumber: line` 行,每个文件组可折叠;`paths`(glob)渲染扁平路径列表。两者都摊平成一个行列表,由高度上限做头/尾切片(默认 16,与 TerminalBlock 相同的切分算法),且都不软换行——长匹配行或路径横向滚动而非折行。当工具截断结果时,banner 摘要把截断前总数折入(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断结果呈现为完整;复制控件写入完整结构化结果,无论是否触及上限或哪些组被折叠。几何镜像 CodeBlock/TerminalBlock。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-primitives/src/SearchBlock.module.css b/packages/client/ui-primitives/src/SearchBlock.module.css index 8f46cdb226..5eef6e4216 100644 --- a/packages/client/ui-primitives/src/SearchBlock.module.css +++ b/packages/client/ui-primitives/src/SearchBlock.module.css @@ -15,8 +15,8 @@ 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. */ +/* The banner: result summary on the left, the copy control holding its + intrinsic width on the right. */ .header { display: flex; align-items: center; diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index dbb4a289ea..843b947491 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -1,13 +1,14 @@ // 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 +// banner (result summary that folds the pre-cap total in when the tool capped +// the result, plus 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 { useCallback, useState, type ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from './clipboard.ts' import css from './SearchBlock.module.css' @@ -39,8 +40,9 @@ export interface SearchFileGroup { 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. + * retained results, not every result the search found. The banner summary + * folds the pre-cap `total` in (`显示 X / 共 N …`) 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`). */ @@ -77,7 +79,7 @@ export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps */ type SearchRow = | { type: 'file'; path: string; count: number; index: number; collapsed: boolean } - | { type: 'match'; lineNumber: number; line: string; key: string } + | { type: 'match'; lineNumber: number; line: string; key: string; fileIndex: number } | { type: 'path'; path: string } /** @@ -97,7 +99,7 @@ function copyText(props: SearchBlockProps): string { /** * 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`. + * the banner summary reports against `total` when the result was capped. * @param props - the card's props. * @returns the retained result count. */ @@ -141,7 +143,7 @@ function toRows(props: SearchBlockProps, collapsed: ReadonlySet): Search 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}` }) + rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}`, fileIndex: index }) } }) return rows @@ -173,7 +175,9 @@ export function SearchBlock(props: SearchBlockProps) { const [collapsed, setCollapsed] = useState>(() => new Set()) const [copied, setCopied] = useState(false) - const rows = useMemo(() => toRows(props, collapsed), [props, collapsed]) + // `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) @@ -204,6 +208,18 @@ export function SearchBlock(props: SearchBlockProps) { // 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 head = capped ? rows.slice(0, headLines) : rows + const tail = 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 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 const renderRow = (row: SearchRow): ReactNode => { if (row.type === 'path') return
{row.path}
@@ -242,7 +258,7 @@ export function SearchBlock(props: SearchBlockProps) { ?
无结果
: (
- {(capped ? rows.slice(0, headLines) : rows).map(row => ( + {head.map(row => (
{renderRow(row)}
))} {hidden > 0 && ( @@ -256,7 +272,10 @@ export function SearchBlock(props: SearchBlockProps) { {expanded ? '收起' : `… 其余 ${hidden} 行`} )} - {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()