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.
This commit is contained in:
Chinesezjc
2026-07-30 17:42:59 +08:00
parent 3e22adab28
commit 71adea8ba4
18 changed files with 1415 additions and 31 deletions

View File

@@ -136,6 +136,60 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* Structured grep result for the search sample (turn 67): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
* with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap.
*/
const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [
{
path: 'packages/client/ui-primitives/src/SearchBlock.tsx',
matches: [
{ lineNumber: 16, line: 'export const DEFAULT_SEARCH_MAX_LINES = 16' },
{ lineNumber: 138, line: 'export function SearchBlock(props: SearchBlockProps) {' },
{ lineNumber: 141, line: ' const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => 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

View File

@@ -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)

View File

@@ -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<ToolRowVariant, ReactNode> = {
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 (
<ToolRow
@@ -37,11 +39,13 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
icon={VARIANT_ICONS[model.variant]}
title={model.title}
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
// it outranks the args-derived summary here exactly as it does in BashRow;
// a search result view's replacement title outranks it the same way.
summary={terminal?.description ?? search?.title ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
terminal={terminal}
search={search}
state={model.state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}

View File

@@ -175,14 +175,15 @@ button.leading {
color: var(--dsw-alias-label-tertiary);
}
/* The two block-shaped expanded bodies: the code variant's run_code program
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
command output through TerminalBlock. Both are drawn by the shared
primitive, so only the row's indentation is this file's concern — the margin
also replaces each primitive's own standalone vertical spacing with the
flow's row rhythm. */
/* The block-shaped expanded bodies: the code variant's run_code program
through CodeBlock (shiki-highlighted TypeScript), a terminal card's command
output through TerminalBlock, and a search card's grouped matches or path
list through SearchBlock. All are drawn by the shared primitive, so only the
row's indentation is this file's concern — the margin also replaces each
primitive's own standalone vertical spacing with the flow's row rhythm. */
.codeBody,
.terminalBody {
.terminalBody,
.searchBody {
margin: 4px 0 4px 22px;
}

View File

@@ -2,16 +2,17 @@
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. The collapsed row is always one
// line; the expanded body is indented gray text, the run_code program through
// CodeBlock, or — for a call whose render intent is a terminal card — the
// command's own output through TerminalBlock, capped at
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
// CodeBlock, a call whose render intent is a terminal card through TerminalBlock
// (capped at CHAT_TERMINAL_MAX_LINES), or a search card through SearchBlock
// (capped at CHAT_SEARCH_MAX_LINES), so the message flow stays scannable. Expand
// state is component-local view state. File-tool summaries are path links that
// open through the host; the row itself is not a details-panel control.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import css from './ToolRow.module.css'
@@ -33,6 +34,13 @@ export interface ToolRowProps {
* expandable (its leading slot never toggles).
*/
terminal?: TerminalCardModel | null | undefined
/**
* Search-card material for a call whose render intent is a search card
* (derived by `searchCardModel`); it replaces the text body when present.
* Null or absent leaves the text body. A call carries at most one card kind,
* so `terminal` and `search` are never both present on the same row.
*/
search?: SearchCardModel | null | undefined
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
@@ -64,6 +72,7 @@ export function ToolRow({
summary,
body,
terminal,
search,
state,
expandOnRowClick = false,
filePath,
@@ -71,16 +80,17 @@ export function ToolRow({
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
const searchBody = search ?? null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet. Terminal
// args expand is off whether or not the open callback is wired yet. Card
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
// or search card and a file link never land on the same row.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = (body !== null && !singleFile) || terminalBody !== null
const expandable = (body !== null && !singleFile) || terminalBody !== null || searchBody !== null
// The text arms take the empty string for a null body: a row expandable
// only through its terminal material renders the terminal body instead, so
// this substitution never shows.
// only through its card material renders that card instead, so this
// substitution never shows.
const text = body ?? ''
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
@@ -164,9 +174,11 @@ export function ToolRow({
)}
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
: searchBody !== null
? <SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
</div>
)
}

View File

@@ -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<A | B, K>` 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, K extends keyof T> = T extends unknown ? Omit<T, K> : never
/** The {@link SearchBlockProps} union minus each render site's own fields. */
type SearchBlockModelProps = DistributiveOmit<SearchBlockProps, 'maxLines' | 'className'>
/**
* 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 },
}
}

View File

@@ -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 <SearchBlock {...search.card} className={css.terminal} />
// 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 <div className={css.empty}>运行中…</div>

View File

@@ -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;
}

View File

@@ -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 <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconSearchOutline16 size={14} />
}
}
/** 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 (
<div className={css.card}>
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{/* The result view's replacement title outranks the args-derived
summary, matching the terminal card's description precedence. */}
<span className={css.summary}>{search?.title ?? model.summary}</span>
</div>
{search !== null && (
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
)}
</div>
)
}
/**
* 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)
},
}

View File

@@ -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()

View File

@@ -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<Extract<ToolResultView, { card: 'search'; kind: 'matches' }>>): 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<Extract<ToolResultView, { card: 'search'; kind: 'paths' }>>): ToolResultView => ({
card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial<RunningToolCall>): 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>): 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>): 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(<GenericToolCard {...ownerProps(settledGrep(), 'grep')} />)
// 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(<GenericToolCard {...ownerProps(settledGlob(), 'glob')} />)
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(<GenericToolCard {...ownerProps(settledGrep({
resultView: { card: 'generic' },
}), 'grep')} />)
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(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
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(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
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(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
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(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: { card: 'generic' },
}), 'grep')} />)
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(<SearchRow {...rowProps(settledGrep({
resultView: resultMatches({ title: '3 matches in 2 files' }),
}), 'grep')} />)
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(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
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<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => 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> = {}): 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')
})
})

View File

@@ -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);
}

View File

@@ -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<number>): 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<ReadonlySet<number>>(() => 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 <div className={css.line}>{row.path}</div>
if (row.type === 'match') {
return (
<div className={css.line}>
<span className={css.lineNumber}>{row.lineNumber}: </span>
{row.line}
</div>
)
}
return (
<button
type="button"
className={css.fileHeader}
aria-expanded={!row.collapsed}
onClick={() => { toggleFile(row.index) }}
>
<span className={css.filePath}>{row.path}</span>
<span className={css.fileCount}>{row.count}</span>
</button>
)
}
return (
<div className={clsx(css.block, className)} data-search={props.kind}>
<div className={css.header}>
<span className={css.summary}>{summaryText(props, shown)}</span>
{truncated && <Pill className={css.truncated}>{`已截断 · 共 ${total}`}</Pill>}
{!empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{empty
? <div className={css.empty}>无结果</div>
: (
<div className={css.body}>
{(capped ? rows.slice(0, headLines) : rows).map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 行`}
</button>
)}
{capped && rows.slice(rows.length - tailLines).map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
)
}

View File

@@ -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'

View File

@@ -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(<SearchBlock kind="matches" truncated={false} total={3} files={[
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const a = 1' }, { lineNumber: 40, line: 'return a' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'const b = 2' }] },
]} />)
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(<SearchBlock kind="matches" truncated={false} total={3} files={[
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] },
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'y' }] },
]} />)
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(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
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(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
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(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
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(<SearchBlock kind="matches" truncated={false} total={0} files={[]} />)
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(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} />)
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(<SearchBlock kind="paths" truncated={false} total={4}
paths={['a', 'b', 'c', 'd']} maxLines={4} />)
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(<SearchBlock kind="paths" truncated={false} total={10} paths={paths} maxLines={4} />)
// 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(<SearchBlock kind="matches" truncated={false} total={10}
files={[group('a.ts', 10)]} maxLines={4} />)
// 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(<SearchBlock kind="paths" truncated={false} total={5}
paths={['a', 'b', 'c', 'd', 'e']} maxLines={1} />)
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(<SearchBlock kind="paths" truncated={false} total={paths.length} paths={paths} />)
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(<SearchBlock kind="matches" truncated total={9} maxLines={2} files={[
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }, { lineNumber: 2, line: 'y' }] },
{ path: 'b.ts', matches: [{ lineNumber: 3, line: 'z' }] },
]} />)
// 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(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
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(<SearchBlock kind="paths" truncated={false} total={1} paths={['a']} />)
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(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} className="x" />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths')
})
})