Merge commit 'refs/codex/pr956/master-live' into worktree/retarget-pr956-current-20260731

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
This commit is contained in:
Tianyi Cui
2026-07-31 17:25:49 +08:00
215 changed files with 7418 additions and 3227 deletions

View File

@@ -15,6 +15,7 @@ import type {
AssistantMessage,
ContentBlock,
MessageSource,
TokenUsage,
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
@@ -137,6 +138,79 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* Structured grep result for the search sample (turn 66): 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 {' },
],
},
{
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
matches: [
{ lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' },
{ lineNumber: 73, line: ' const search = searchCardModel(block)' },
{ lineNumber: 90, line: ' <SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />' },
{ lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" },
],
},
]
/**
* The model-facing grep render text for the sample — what a UI without a search
* card shows, attached as the view's `content`. Mirrors the real grep
* presenter's shape (see formatGrepOutput in dsh-tool-fs-search): a
* `Found X of Y matches` header, the matches grouped under file headers with
* `Line N:` rows, then a spill-recovery footer.
*/
const SEARCH_MATCHES_TEXT = [
'Found 9 of 42 matches',
'',
...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 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-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-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')
/**
* Read-card sample for the read turn: a WINDOW past an offset, so the line
* numbers start above 1 (the card's gutter keeps the file's own numbering) and
@@ -165,7 +239,7 @@ const READ_SAMPLE_TOTAL = 180
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
/**
* The structured `web_search` result view for fixture turn 67, authored inline
* The structured `web_search` result view for the web-search turn, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
@@ -195,7 +269,7 @@ const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'sear
truncated: true,
}
/** The `web_fetch` result view for fixture turn 68, authored inline for the same reason. */
/** The `web_fetch` result view for the web-fetch turn, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
@@ -254,6 +328,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
/** Deterministic provider billing attached to fixture assistant messages. */
function fixtureUsage(turn: number, step: number): TokenUsage {
return {
inputTokens: 20 + turn % 5,
outputTokens: 8 + step,
cacheReadTokens: turn === 0 ? 0 : 80,
cacheWriteTokens: turn % 10 === 0 ? 4 : 0,
}
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / steering / context. */
function buildAlphaLog(): SessionEvent[] {
@@ -261,7 +345,17 @@ function buildAlphaLog(): SessionEvent[] {
let time = Date.now() - 3_600_000
const push = (e: Record<string, unknown>): number => {
const seq = events.length
events.push({ seq, time: (time += 800), ...e })
const data = e['data'] as Record<string, unknown> | undefined
const authored = e['type'] === 'assistant/message' && data !== undefined
? {
...e,
data: {
...data,
usage: fixtureUsage(data['turn'] as number, data['step'] as number),
},
}
: e
events.push({ seq, time: (time += 800), ...authored })
return seq
}
for (let turn = 0; turn < 60; turn++) {
@@ -398,7 +492,17 @@ 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)
// Turn 66: the read sample — a WINDOW past an offset so the card draws file
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
// larger `total`), `glob` emits `shape: '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)
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
@@ -409,9 +513,9 @@ function buildAlphaLog(): SessionEvent[] {
// this fixture. The read render intent is result-side only, so its pending
// call stays a generic `kind: 'read'` card; presentResult carries the
// structured window.
toolTurn(66, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
// Turns 67-68: the web render intent — a web_search whose result view carries
// Turns 69-70: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
@@ -420,11 +524,11 @@ function buildAlphaLog(): SessionEvent[] {
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(67, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(68, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(69, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(71, '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).
@@ -486,6 +590,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
}
// 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 }
// The web tools keep a GENERIC pending card and add the `web` result card
// only at result time (the contract's result-only web shape); their pending
// kind matches the result kind so a call and its result read as one category.
@@ -501,6 +612,18 @@ 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. The view holds no
// result text — a UI without a search card falls back to the raw tool/result
// content — so the truncation recovery footer rides that raw content (the
// `toolTurn` message text), not the view. `total` exceeds the retained count so
// the card shows its capped indicator.
if (name === 'grep') {
return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }
}
if (name === 'glob') {
return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }
}
// The read result is the structured window the tool projects through
// `presentationMeta`; the fixture authors it inline (it cannot import the
// tool). Keyed on the name because the read pending call is a generic card,
@@ -625,6 +748,113 @@ function permissionSelectOf(
}
}
interface FixtureTokenUsageProjection {
uncachedInputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
interface FixtureUsageSample {
turn: number
step: number
usage: TokenUsage
}
/** Read one provider usage sample from either durable carrier. */
function usageSampleOf(event: SessionEvent): FixtureUsageSample | undefined {
const item = event as unknown as {
type: string
data: {
turn?: number
step?: number
usage?: TokenUsage
chunk?: { type?: string; usage?: TokenUsage }
}
}
const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage'
? item.data.chunk.usage
: item.type === 'assistant/message'
? item.data.usage
: undefined
return usage === undefined || item.data.turn === undefined || item.data.step === undefined
? undefined
: { turn: item.data.turn, step: item.data.step, usage }
}
/** Fixture parallel of token-meter's last-sample-replacing usage projection. */
function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection {
const totals: FixtureTokenUsageProjection = {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
let last: {
turn: number
step: number
buckets: FixtureTokenUsageProjection
} | null = null
for (const event of log) {
const sample = usageSampleOf(event)
if (sample === undefined) continue
const buckets: FixtureTokenUsageProjection = {
uncachedInputTokens: sample.usage.inputTokens,
outputTokens: sample.usage.outputTokens,
cacheReadTokens: sample.usage.cacheReadTokens ?? 0,
cacheWriteTokens: sample.usage.cacheWriteTokens ?? 0,
}
const previous = last?.turn === sample.turn && last.step === sample.step
? last.buckets
: undefined
totals.uncachedInputTokens += buckets.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0)
totals.outputTokens += buckets.outputTokens - (previous?.outputTokens ?? 0)
totals.cacheReadTokens += buckets.cacheReadTokens - (previous?.cacheReadTokens ?? 0)
totals.cacheWriteTokens += buckets.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0)
last = { turn: sample.turn, step: sample.step, buckets }
}
return totals
}
interface FixtureRequestContext {
provider: string
model: string
contextWindow?: number
}
/** Latest log-only route context, or undefined before any request ran. */
function lastRequestContext(
log: readonly SessionEvent[],
): FixtureRequestContext | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'request/context')
return event === undefined
? undefined
: (event as unknown as { data: FixtureRequestContext }).data
}
/**
* Fixture parallel of token-meter's request-pressure projection: the last
* provider-reported prompt size paired with the last recorded capacity. The
* two need not come from one request — see the token-meter README.
*/
function contextPressureOf(
log: readonly SessionEvent[],
): { pressureTokens?: number; contextWindow?: number } {
let pressureTokens: number | undefined
for (const event of log) {
const sample = usageSampleOf(event)
if (sample === undefined) continue
pressureTokens = sample.usage.inputTokens
+ (sample.usage.cacheReadTokens ?? 0)
+ (sample.usage.cacheWriteTokens ?? 0)
}
const contextWindow = lastRequestContext(log)?.contextWindow
return {
...pressureTokens === undefined ? {} : { pressureTokens },
...contextWindow === undefined ? {} : { contextWindow },
}
}
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
@@ -639,12 +869,32 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['plan'] = planViewOf(log)
// Always present (GoalService unit composed): null before create / after clear.
values['goal'] = backscanGoal(log)
// Always present (token-meter composed): full-log provider billing.
values['tokenUsage'] = tokenUsageOf(log)
// Always present (token-meter composed): last request pressure and capacity.
values['contextPressure'] = contextPressureOf(log)
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
// One usage sample advances both token-meter units.
if (usageSampleOf(event) !== undefined) {
return [
{ type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq },
{ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
]
}
if (type === 'request/context') {
return [{
type: 'session/projection',
sessionId: id,
key: 'contextPressure',
value: contextPressureOf(log),
seq: event.seq,
}]
}
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
@@ -1341,7 +1591,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
replays.delete(id)
const done = pieces.slice(0, i).join('')
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
append(id, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn,
step,
message: assistantMessage(text(aborted ? `${done}(已中断)` : done)),
usage: fixtureUsage(turn, step),
},
})
append(id, { type: 'step/end', data: { turn, step } })
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
setRunning(id, false)
@@ -1610,6 +1869,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
if (lastRequestContext(logOf(id))?.model !== target.model) {
append(id, {
type: 'request/context',
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
})
}
startReply(
id,
turn,

View File

@@ -141,6 +141,14 @@ describe('createFixtureApi', () => {
},
plan: { active: false, pending: false },
goal: null,
tokenUsage: {
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
// No request ran, so neither pressure nor capacity is known yet.
contextPressure: {},
} },
})
})
@@ -275,6 +283,17 @@ describe('createFixtureApi', () => {
expect(types).toContain('assistant/chunk')
expect(types).toContain('assistant/message')
expect(types.at(-1)).toBe('turn/end')
// Capacity is durable log state, not a transient frame: the prompt path
// records request/context and the projection carries it to the client.
expect(types).toContain('request/context')
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'tokenUsage'
&& (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true)
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'contextPressure'
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
// Idle cancel: no replay in flight, must not explode; running flips false.
@@ -306,7 +325,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 8) abort.abort()
if (envelopes.length >= 10) abort.abort()
}
return envelopes
}
@@ -314,16 +333,18 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
// Projection baseline frames follow subscribed (domain units + token usage).
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 7752eb98a5e279e541eecb3ef4e7d1ac46729a48
README.zh.md: ca72222e2f0b8aa69c11a4bbf82d862222f46fa4
README.md: 66120f222de4b4d4707430a1ec310c6fe801c0c5
README.zh.md: f5e953f363733341400a292a1946b4e298858b77

View File

@@ -24,6 +24,8 @@ A tool call declaring the `diff` render intent (the `write`/`edit` tools) render
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
@@ -34,6 +36,8 @@ Per-session UI state for selection and the active view lives in the declared cha
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience

View File

@@ -22,6 +22,8 @@
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试Host 的 running 位只控制实时动画随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall瀑布式事件工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
@@ -34,6 +36,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI模型选择器不增加圆环或附属控件。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
## 模型体验
@@ -47,7 +51,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
- **统计行的耗时只覆盖窗口内消息流**LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
- **已发送的 user 消息无法编辑**user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
@@ -60,6 +61,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -20,6 +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-row.tsx'
import { readToolview } from './toolviews/read-row.tsx'
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
@@ -321,6 +322,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 read row rides the same seam (a product registration, not a sample):
// Read · {path} chrome with the file's read card resident below it.
ctx.plugin(readToolview)

View File

@@ -10,6 +10,7 @@ import {
IconThinkOutline14, ReadBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
@@ -38,6 +39,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const search = searchCardModel(block)
const read = readCardModel(block, cwd)
const diff = diffCardModel(block)
const web = webCardModel(block)
@@ -55,8 +57,9 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
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
// args interaction. A diff card is not an args body: a write/edit row is
// single-file AND carries a diff, so the card expands under the path link.
@@ -64,6 +67,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
search={search}
diff={diff}
state={state}
filePath={model.filePath}

View File

@@ -3,43 +3,35 @@
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import css from './StatsLine.module.css'
interface UsageTotals {
interface WindowStats {
turns: number
steps: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
inputTokens: number
outputTokens: number
cacheHitPct: number | null
}
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
interface UsageLike {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
}
/**
* Fold assistant and tool-result nodes into display totals.
* Fold assistant and tool-result nodes into the window-scoped display totals.
*
* Counts and wall times describe the loaded window on purpose — they answer
* "what is on screen". Token accounting deliberately does NOT come from here:
* the window is paged and compaction rewrites it, so billing rides the durable
* `tokenUsage` projection instead.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
* @returns visible counts and summed wall times.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
const turns = new Set<number>()
let steps = 0
let llmMs = 0
let toolMs = 0
let input = 0
let output = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
@@ -51,22 +43,8 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
output += usage.outputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
llmMs,
toolMs,
inputTokens: input + cacheRead,
outputTokens: output,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
return { turns: turns.size, steps, llmMs, toolMs }
}
/**
@@ -94,21 +72,82 @@ export function formatDuration(ms: number): string {
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
/**
* Cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns rounded integer percent, or null when no input was billed.
*/
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
const denominator = billedInputTokens(usage)
return denominator === 0
? null
: Math.round(usage.cacheReadTokens / denominator * 100)
}
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
/** Sum the three disjoint prompt-side billing buckets. */
function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
interface ContextOccupancy {
percent: number
contextWindow: number
}
/**
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact measurement of one
* request (see the token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy and its denominator, or null until both values are known.
*/
export function contextOccupancy(
pressure: ContextPressureProjection | undefined,
): ContextOccupancy | null {
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
return {
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
contextWindow: pressure.contextWindow,
}
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
}
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
const groups: string[] = []
if (stats.steps > 0) {
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
}
const context = contextOccupancy(pressure)
if (context !== null) {
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
}
// Billing rides the durable projection, so these survive paging and
// compaction. Suppress the empty projection on a brand-new session.
if (usage !== undefined
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
groups.push(
`Input ${formatTokens(billedInputTokens(usage))} tok`
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
)
}
if (groups.length === 0) return null
return (
<div className={css.root}>
{groups.map((group, i) => (

View File

@@ -246,17 +246,29 @@
color: var(--dsw-alias-state-error-primary);
}
/* 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 4px;
}
/* The recovery footer for a capped search: the result text (its `Full … stored
at …` locator) below the card in the muted tone, since the card holds only the
retained rows. Same column indent as the card body. */
.searchRecovery {
margin: 4px 0 4px 4px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}
/* A write/edit diff renders through DiffBlock; like the terminal card it draws
its own surface, so only the row indentation is this file's concern. */
.diffBody {

View File

@@ -3,23 +3,25 @@
// separator dot + FILL-truncated summary, drawn through the shared
// DisclosureRow chrome with the whole row as the expand toggle (click /
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, or terminal material is expandable;
// the summary stays inline while open, except Think, whose body opens with
// the same first line and would repeat it.
// one line; every row with body, output, terminal, or search material is
// expandable; the summary stays inline while open, except Think, whose body
// opens with the same first line and would repeat it.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a terminal
// card's command output through TerminalBlock — lives in a max-height scroll
// container so a long payload scrolls internally instead of taking over the
// message flow; Think's prose is the exception and flows uncapped like
// message text. Expand state is component-local view state. File-tool
// summaries are path links that open through the host (stopPropagation keeps
// the two gestures independent); an error row's collapsed summary is the
// text input/output, the run_code program through CodeBlock, a terminal
// card's command output through TerminalBlock, or a search card's grouped
// matches / path list through SearchBlock (capped at CHAT_SEARCH_MAX_LINES) —
// lives in a max-height scroll container so a long payload scrolls internally
// instead of taking over the message flow; Think's prose is the exception and
// flows uncapped like message text. Expand state is component-local view state.
// File-tool summaries are path links that open through the host (stopPropagation
// keeps the two gestures independent); an error row's collapsed summary is the
// failure's first line in the error color.
import { useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, DiffBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -49,10 +51,17 @@ export interface ToolRowProps {
* expandable.
*/
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`, `search`, and `diff` are never both present on the same row.
*/
search?: SearchCardModel | null | undefined
/**
* Diff-card material for a call whose render intent is a diff card (derived by
* `diffCardModel`); it replaces the text body when present, the same way
* `terminal` does. A call carries at most one card intent, so the two are
* `terminal` does. A call carries at most one card intent, so the cards are
* never both set.
*/
diff?: DiffCardModel | null | undefined
@@ -103,6 +112,7 @@ export function ToolRow({
output,
errorSummary,
terminal,
search,
diff,
state,
filePath,
@@ -111,9 +121,12 @@ export function ToolRow({
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
const searchBody = search ?? null
const diffBody = diff ?? null
const outputText = output ?? null
const expandable = body !== null || outputText !== null || terminalBody !== null || diffBody !== null
// A search or diff card replaces the text body; a call carries at most one
// card kind, so terminal, search, and diff are never both present on a row.
const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null || diffBody !== null
const open = expanded && expandable
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
@@ -185,40 +198,51 @@ export function ToolRow({
className={css.terminalBody}
/>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
: searchBody !== null
? (
<>
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
{/* A capped search's recovery locator lives only in the result
text; show it below the card so the dropped rows survive. */}
{searchBody.recovery !== undefined && (
<div className={css.searchRecovery}>{searchBody.recovery}</div>
)}
</>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"

View File

@@ -0,0 +1,159 @@
/**
* 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.
*
* A capped result also carries a recovery locator (grep/glob's `Full … stored
* at …` footer) in the raw `tool/result` content, not in the structured
* matches/paths the view carries. Since both render sites replace that raw
* result with the card, this derivation surfaces the block's own result text as
* {@link SearchCardModel.recovery} so the one path to the dropped rows is not
* lost.
* @module
*/
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
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
/**
* The raw `tool/result` text, flattened, surfaced only when the search was
* capped. The card renders the retained matches or paths, but the recovery
* locator a capped result carries — grep/glob's `Full … stored at: <locator>`
* footer, the one way to reach the rows the cap dropped — lives only in the raw
* result text, which the card replaces. A UI that shows the card would
* otherwise lose it. Absent when the result was not capped (the card holds
* every result) or the block carries no text.
*/
recovery: string | undefined
}
/**
* Whether every file group in a matches view is structurally valid: the wire
* frame carries `shape` and `card` as strings the host schema checks, but not the
* grouped shape, so a version mismatch or loose producer could deliver
* `shape: 'matches'` with a missing or malformed `files`. Rendering that would
* crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the
* generic path instead.
* @param files - the candidate `files` field off the untrusted result view.
* @returns whether `files` is a valid {@link SearchFileGroup} array.
*/
function isValidFiles(files: unknown): files is SearchFileGroup[] {
return Array.isArray(files) && files.every(file =>
typeof file === 'object' && file !== null
&& typeof (file as { path?: unknown }).path === 'string'
&& Array.isArray((file as { matches?: unknown }).matches)
&& (file as { matches: unknown[] }).matches.every(match =>
typeof match === 'object' && match !== null
&& typeof (match as { lineNumber?: unknown }).lineNumber === 'number'
&& typeof (match as { line?: unknown }).line === 'string'))
}
/**
* Flatten a settled tool result's content blocks to their text, joined by
* newlines. The search view carries no result text — a UI without a card falls
* back to the raw `tool/result` content — so the truncation recovery footer is
* read from the block's own content here. Non-text blocks (a search result
* carries none) are skipped.
* @param content - the result node's content blocks.
* @returns the joined text, or undefined when empty.
*/
function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined {
const text = content
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
.join('\n')
return text === '' ? undefined : text
}
/**
* 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, a `card: 'search'` view whose `shape` is neither
* `matches` nor `paths` (equally untrusted wire data), and a generic result a
* `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
* the generic path).
* @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 }
// The recovery footer only matters when the tool capped the result: an
// uncapped card holds every match/path, so the raw text adds nothing the card
// does not already show. When capped, the raw result's `Full … stored at …`
// locator is the only path to the dropped rows, so surface it.
const recovery = result.truncated ? flattenContent(block.content) : undefined
if (result.shape === 'matches') {
// `files` rides the untrusted wire frame: the host schema checks `card`/`shape`
// strings but not the grouped shape, so validate it before SearchBlock, which
// would crash on a missing/malformed `files`. An invalid shape falls to generic.
if (!isValidFiles(result.files)) return null
return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } }
}
// `shape` rides the same untrusted wire frame as `card`, so a version mismatch
// or a loose protocol producer could deliver a `card: 'search'` subtype this
// client does not compile. Guard the paths shape explicitly: an unknown shape
// falls to the generic path rather than being rendered as a paths card, which
// would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive.
if (result.shape !== 'paths') return null
// `paths` is likewise unchecked by the wire schema; a known shape with a
// missing/malformed array would crash the paths card at `.map`.
if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }
}

View File

@@ -0,0 +1,45 @@
// Shared toolview-row helpers for the keyed rows whose card is resident below a
// summary (SearchRow, FileMutationRow): the visually hidden run-state label and
// the flattened settled-result text for the fallback arm a card cannot render.
// Both are pure functions of a frozen call slice — no chat-domain imports — so a
// row stays a thin ToolRowProps consumer.
import type { ToolRowProps } from './slots.ts'
import type { ToolRowState } from './tool-call-model.ts'
/**
* Visually hidden run-state label for a row's leading `StateDot` (which is
* `aria-hidden`), so assistive technology still announces the state. Returns
* null for the settled-ok state, which needs no spoken label.
* @param state - the row's run state.
* @returns the label, or null when none is needed.
*/
export function rowStateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* A settled result's text, flattened from its content blocks, for the fallback
* arm a keyed row shows when its card cannot render the result — an errored call
* (the tool emits no result view on error) or a settled call with no card view
* (a nested `run_code` sub-dispatch, a legacy generic result). The keyed row owns
* the render slot, so without this the model-facing text would have nowhere to
* go. Falls back to the error name/code when the result carries no text block.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
export function rowResultText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
if (item.type === 'text') parts.push(item.text)
}
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
const text = parts.join('\n')
return text === '' ? null : text
}

View File

@@ -101,13 +101,24 @@
font: var(--dsw-font-xs-13);
}
/* A card body (terminal or diff) sits directly under its section label, so it
drops the primitive's standalone vertical margin; the section owns the
spacing. Card-neutral: no terminal- or diff-specific value. */
/* A card body (terminal, search, or diff) sits directly under its section
label, so it drops the primitive's standalone vertical margin; the section
owns the spacing. Card-neutral: no card-specific value. */
.cardBody {
margin: 0;
}
/* The recovery footer for a capped search: the result text (its `Full … stored
at …` locator) below the card in the muted tone, since the card holds only the
retained rows. */
.searchRecovery {
margin: 6px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
/* The read and web cards sit directly under their section label, same as the
terminal card: drop the primitive's standalone vertical margin. */
.read,

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, DiffBlock, ReadBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } 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 { readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
@@ -130,13 +131,15 @@ 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. A read-card call
* renders through the shared ReadBlock at that same full height, so the whole
* returned window is line-numbered and highlighted. A diff-card call — a
* write/edit's applied change — renders through the shared DiffBlock at the same
* full height. A web-card call — a `web_search`/`web_fetch` result — renders
* through WebBlock at its own full source-list allowance. Every other call, and
* a running call with no 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, with a capped search's recovery footer below it.
* A read-card call renders through the shared ReadBlock at that same full height,
* so the whole returned window is line-numbered and highlighted. A diff-card
* call — a write/edit's applied change — renders through the shared DiffBlock at
* the same full height. A web-card call — a `web_search`/`web_fetch` result —
* renders through WebBlock at its own full source-list 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.
* @param props.t - the panel's locale seat, passed down as a plain prop.
@@ -156,6 +159,19 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const search = searchCardModel(material.block)
if (search !== null) {
return (
<>
<SearchBlock {...search.card} className={css.cardBody} />
{/* A capped search's recovery locator lives only in the result text;
show it below the card so the dropped rows stay reachable. */}
{search.recovery !== undefined && (
<div className={css.searchRecovery}>{search.recovery}</div>
)}
</>
)
}
const read = readCardModel(material.block, cwd)
// The panel takes the primitive's own default cap, not the row's tighter one:
// it is the single-call reading surface, so the whole window is available.

View File

@@ -18,6 +18,7 @@ import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
import css from './file-mutation-row.module.css'
function leadingFor(state: ToolRowState) {
@@ -29,37 +30,6 @@ function leadingFor(state: ToolRowState) {
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* A settled result's text, flattened from its content blocks, for the arm that
* shows a failure the diff card cannot: write/edit return `undefined` from
* `presentResult` on `result.isError`, so an errored mutation has no diff card,
* and the keyed row is not a details-panel target. Without this the failure —
* an `old_string` that did not match, a permission denial — would read as a bare
* red dot with the model-facing error text nowhere on screen.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
function errorText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
if (item.type === 'text') parts.push(item.text)
}
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
const text = parts.join('\n')
return text === '' ? null : text
}
/**
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
* with the applied diff resident below it. The summary is a path link (a file
@@ -70,11 +40,11 @@ function errorText(block: ToolRowProps['block']): string | null {
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) {
const model = toolRowModel(toolName, block, cwd)
const diff = diffCardModel(block)
const status = stateStatus(model.state)
const status = rowStateStatus(model.state)
const filePath = model.filePath
// An errored mutation has no diff card (presentResult returns undefined on
// isError); surface its result text so the failure is more than a red dot.
const failure = diff === null && model.state === 'error' ? errorText(block) : null
const failure = diff === null && model.state === 'error' ? rowResultText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant={model.variant} data-state={model.state}>

View File

@@ -0,0 +1,117 @@
/* 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;
}
/* The result text for an errored search, indented to the card's own column and
in the error tone, standing in for the search card the failure path does not
produce. */
.failure {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-state-error-primary);
}
/* The recovery footer for a capped search: the model-facing result text (its
`Full … stored at …` locator) shown below the card in the muted tone, since
the card holds only the retained rows. Same column indent as the card body. */
.recovery {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,96 @@
// 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 { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
import css from './search-row.module.css'
/** Leading-slot glyph substitution: the search icon yields to the terminal
* 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} />
}
}
/**
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
* completed search's card resident below it, and — when the result was capped —
* the recovery footer below the card. The summary row is not a details-panel
* control, so the card's copy, per-file collapse, and expand controls are the
* row's only interactions. Registered under both `grep` and `glob`; the derived
* model's `kind` decides the card shape.
*/
export function SearchRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const search = searchCardModel(block)
const status = rowStateStatus(model.state)
// A settled call with no search card — an errored search (grep/glob emit no
// result view on error), a successful nested run_code sub-dispatch, or a
// legacy generic result — has its model-facing text nowhere else to go, since
// the keyed SearchRow owns this render slot. Surface it as the fallback body.
// A running call ('kind' absent) has no result to flatten; rowResultText
// returns null for it, so the arm stays closed until settle.
const settled = 'kind' in block
const fallback = search === null && settled ? rowResultText(block) : null
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} />
)}
{/* A capped search drops rows from the card; its recovery locator (the
`Full … stored at …` footer) lives only in the result text, so show it
below the card so the one path to the dropped rows survives. */}
{search?.recovery !== undefined && <div className={css.recovery}>{search.recovery}</div>}
{fallback !== null && <div className={css.failure}>{fallback}</div>}
</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

@@ -84,14 +84,15 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the search rows, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// file-mutation registrant claims both write and edit for the diff card; the
// web rows register one component under both web tool names.
// one search row registers under both grep and glob; the file-mutation
// registrant claims both write and edit for the diff card; the web rows
// register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// 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

@@ -445,14 +445,19 @@ describe('small branch tails', () => {
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
})

View File

@@ -58,7 +58,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
describe('deriveStats', () => {
it('folds turns/steps/token split and cache hit percentage', () => {
it('counts turns and steps and never folds node usage into accounting', () => {
const stats = deriveStats([
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
@@ -66,12 +66,12 @@ describe('deriveStats', () => {
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
expect(stats.inputTokens).toBe(1100)
expect(stats.outputTokens).toBe(100)
expect(stats.cacheHitPct).toBe(82)
// Window-scoped by design: the paged window is not an accounting source, so
// the fold exposes no token fields at all (billing rides the projection).
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
})
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
@@ -79,7 +79,6 @@ describe('deriveStats', () => {
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
expect(stats.toolMs).toBe(0)
expect(stats.cacheHitPct).toBeNull()
})
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
@@ -116,22 +115,105 @@ describe('formatters', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
return (key: string) => values[key]
}
it('renders the grouped stats row and hides with zero steps', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
})
function props(
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsLineProps {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
// No timing on the fixture: the duration group drops out whole.
// No timing on the fixture: the duration group drops out whole. Tokens come
// from the projection, so paging the window cannot change them.
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source)} />)
const emptyView = render(<StatsLine {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
contextPressure: {},
})} />)
expect(emptyView.container.textContent).toBe('')
})
it('keeps durable token and context groups after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent)
.toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok')
})
it('renders context occupancy only when the projection knows a capacity', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const withCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
// Pressure without capacity has no denominator: the group drops out.
const noCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000 },
})} />)
expect(noCapacity.container.textContent).not.toContain('Context')
// Capacity arrives before usage in the log; no provider sample means there
// is no numerator yet, rather than a synthetic 0%.
const noPressure = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { contextWindow: 128_000 },
})} />)
expect(noPressure.container.textContent).not.toContain('Context')
})
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
// Capacity and pressure are independent last-wins fields, so a model switch
// can pair a smaller new window with the previous route's larger prompt.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent).toContain('Context 100% of 128K')
})
it('drops every token group when no projection is composed', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps')
})
it('omits cache hit when nothing was billed on the input side', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
})
it('includes cache writes in billed input and the cache-hit denominator', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: {
uncachedInputTokens: 10,
outputTokens: 7,
cacheReadTokens: 90,
cacheWriteTokens: 100,
},
})} />)
expect(view.container.textContent)
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0

View File

@@ -43,20 +43,24 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
it('StatsLine counts window nodes but drops every token group without a projection', () => {
// Node `usage` is deliberately ignored: billing rides the durable
// tokenUsage projection, so an absent projection leaves counts only.
const snap = {
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
// outputTokens absent: the tokens sum's ?? 0 arm for output.
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
<StatsLine
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={() => undefined}
/>,
)
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
expect(view.container.textContent).toBe('2 turns · 3 steps')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

View File

@@ -0,0 +1,409 @@
// @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, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
import { zh } from '../src/client/locales.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
afterEach(cleanup)
/** Conversation-locale translate stub for the render sites' `t` seat. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
/** 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'; shape: 'matches' }>>): ToolResultView => ({
card: 'search', shape: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
],
truncated: false, total: 3, ...over,
})
/** A glob result view: a flat path list. */
const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'paths' }>>): ToolResultView => ({
card: 'search', shape: '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,
recovery: 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', () => {
// Empty block content isolates the truncation signal from the recovery arm.
expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
title: undefined,
recovery: undefined,
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
})
})
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()
})
it('returns null for a card:search view whose shape this version does not compile', () => {
// `shape` rides the same untrusted wire frame as `card`; a subtype this client
// does not know must fall to the generic path, never render as a paths card
// that would crash SearchBlock on an absent `paths`.
const futureShape = {
card: 'search', shape: 'future', truncated: false, total: 0,
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull()
})
it('returns null for a known shape whose structured shape is missing or malformed', () => {
// The host wire schema checks the `card`/`shape` strings but not the grouped
// shape, so a version mismatch could deliver shape:'matches' with no `files`
// (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at
// `.reduce`/`.map`; the derivation drops to the generic path instead.
const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
const badFile = {
card: 'search', shape: 'matches', truncated: false, total: 1,
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
const badPaths = {
card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42],
} as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
})
it('surfaces the recovery text only when the result was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
// The recovery locator lives in the raw tool/result content (the view carries
// no text), surfaced only when the card capped the result.
const capped = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}))
expect(capped?.recovery).toBe(recovery)
// Not capped: the card holds every match, so the raw content adds nothing and
// is dropped.
const whole = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: false }),
}))
expect(whole?.recovery).toBeUndefined()
// Capped but the block carries no text: nothing to surface.
const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) }))
expect(noText?.recovery).toBeUndefined()
})
})
describe('chat row search body (GenericToolCard fallback)', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
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()
toggleRow(view)
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')} />)
toggleRow(view)
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')} />)
toggleRow(view)
expect(view.getByText(/"pattern"/)).toBeTruthy()
expect(searchKindOf(view.container)).toBeNull()
})
it('the expanded body shows the recovery footer below a capped card', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<GenericToolCard {...ownerProps(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
})
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('surfaces the result text when an errored search has no card', () => {
// grep/glob return no presentResult on error → no card; the row shows the
// model-facing error text instead of a bare red dot.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null,
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
}), 'grep')} />)
expect(searchKindOf(view.container)).toBeNull()
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
})
it('surfaces the result text for a settled non-error call with no card', () => {
// A successful nested run_code sub-dispatch (backend computes no
// presentationMeta, so resultView is null) or a legacy generic result settles
// with search === null and state ok. The keyed SearchRow owns the slot, so
// without the widened arm the content would be lost behind a bare summary.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: false, resultView: null,
content: [{ type: 'text', text: 'nested run_code output line' }],
}), 'grep')} />)
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
expect(searchKindOf(view.container)).toBeNull()
expect(view.getByText('nested run_code output line')).toBeTruthy()
})
it('renders the recovery footer below the card when the search was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<SearchRow {...rowProps(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
it('shows no recovery footer for an uncapped search', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.container.textContent).not.toMatch(/stored at/)
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null, content: [],
error: { name: 'ToolError', code: 'timeout' },
}), 'grep')} />)
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
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: [], archivedSessionIds: [], 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()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], 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('renders the recovery footer below the card for a capped search', () => {
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
const view = mount(snapshot({
nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })],
}), globTarget)
expect(searchKindOf(view.container)).toBe('paths')
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
})
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('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1')
})
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../plan/plan-mode"
},

View File

@@ -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: 58be01d56a85c66a144df3f8054840961e987403
README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e
README.md: 6430a789c15634538a38d6581df50a489522db55
README.zh.md: 78249612cce3148fcececded40c529682450460a

View File

@@ -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), TerminalBlock, DiffBlock, and WebBlock. 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, SearchBlock, DiffBlock, and WebBlock. 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).
## Diff rendering
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量、TerminalBlock、DiffBlock以及 WebBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量、TerminalBlock、SearchBlock、DiffBlock以及 WebBlock。契约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)。
## Diff 渲染
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `error token在新增行`+ `success token之上、同文件第二个 hunk 前一个 `⋯` gap以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16`TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap使多文件复制保持可归属并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock``+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。

View File

@@ -0,0 +1,120 @@
/* 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 copy control holding its
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);
}
.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,277 @@
// SearchBlock: the search surface for a completed content or path search — a
// 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, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { headTailCap } from './head-tail-cap.ts'
import { useCopyFeedback } from './use-copy-feedback.ts'
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. 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`). */
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; fileIndex: number }
| { 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 banner summary reports against `total` when the result was capped.
* @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. When the search was capped it reads `显示 X / 共 N …` so
* the retained count and the pre-cap total sit in one clause (mirroring the read
* card's `显示 X / Y 行`); when it was not capped it is a plain count of what the
* card holds. The unit — `处匹配 · K 个文件` for grep, `个路径` for glob — trails
* the count either way.
* @param props - the card's props.
* @param shown - the retained result count from {@link shownCount}.
* @param truncated - whether the search was capped.
* @param total - the pre-cap total the truncation clause reports.
* @returns the summary text.
*/
function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string {
const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}`
return props.kind === 'paths'
? `${count} 个路径`
: `${count} 处匹配 · ${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}`, fileIndex: index })
}
})
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())
// `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 { copied, onCopy } = useCopyFeedback(copyText(props))
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, capped, headLines, tailLines } = headTailCap(rows.length, maxLines, expanded)
const head = capped ? rows.slice(0, headLines) : rows
const naturalTail = capped ? rows.slice(rows.length - tailLines) : []
// When the tail slice begins inside a file's matches, its own header sits
// 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 = naturalTail[0]
const tailHeader = tailLead?.type === 'match'
&& !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex)
? rows.find((row): row is Extract<SearchRow, { type: 'file' }> =>
row.type === 'file' && row.index === tailLead.fileIndex)
: undefined
// The restored header is itself a row. Left extra it would push the card to
// maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop
// the tail's first row (the match whose header this is) for it. Visible rows
// hold at maxLines and `hidden` stays exact; the dropped match joins the
// hidden middle.
const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1)
const renderRow = (row: SearchRow): ReactNode => {
if (row.type === 'path') return <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, truncated, total)}</span>
{!empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{empty
? <div className={css.empty}></div>
: (
<div className={css.body}>
{head.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>
)}
{tailHeader !== undefined && (
<div key={`tailHeader:${rowKey(tailHeader)}`}>{renderRow(tailHeader)}</div>
)}
{tail.map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
)
}

View File

@@ -8,7 +8,8 @@
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
import { writeClipboard } from './clipboard.ts'
import { headTailCap } from './head-tail-cap.ts'
import { useCopyFeedback } from './use-copy-feedback.ts'
import { Pill } from './Pill.tsx'
import { StateDot, type StateDotState } from './StateDot.tsx'
import css from './TerminalBlock.module.css'
@@ -202,18 +203,9 @@ export function TerminalBlock({
return terminated ? parsed.slice(0, -1) : parsed
}, [text])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The raw output, never the rendered tree: the prompt line and the status
// pill are chrome the user did not run.
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, text])
// The raw output, never the rendered tree: the prompt line and the status pill
// are chrome the user did not run.
const { copied, onCopy } = useCopyFeedback(text)
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
@@ -232,12 +224,7 @@ export function TerminalBlock({
// the raw text drew an output box of blank rows plus a copy control for
// invisible bytes, and hid the placeholder that belongs there.
const empty = lines.every(line => line.every(span => span.text.trim() === ''))
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as the TUI transcript's collapsed tool card, so a
// command's head and tail slices agree between the two front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded)
return (
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>

View File

@@ -0,0 +1,33 @@
// Head/tail height-cap arithmetic shared by the block primitives (TerminalBlock,
// SearchBlock) and matching the TUI transcript's collapsed tool card, so a long
// result's head and tail slices agree across every surface. The split is
// `ceil(maxLines / 2)` head rows and the remainder as tail rows; a result within
// the cap shows every row and hides none.
/** The head/tail split metrics for a capped list. */
export interface HeadTailCap {
/** Rows beyond the cap (list length maxLines); ≤ 0 means nothing is hidden. */
hidden: number
/** Whether the list is over the cap and not expanded, so it shows a head/tail slice. */
capped: boolean
/** Head-slice row count: `ceil(maxLines / 2)`. */
headLines: number
/** Tail-slice row count: the remainder after the head. */
tailLines: number
}
/**
* Compute the head/tail cap metrics for a list of `total` rows against `maxLines`,
* given whether the surface is expanded. Pure arithmetic; the caller slices its
* own rows with `headLines`/`tailLines` so a block can layer its own concerns
* (SearchBlock restores a tail file header) on top.
* @param total - the list's row count.
* @param maxLines - the collapsed-height cap in rows.
* @param expanded - whether the surface is expanded (uncaps the list).
* @returns the split metrics.
*/
export function headTailCap(total: number, maxLines: number, expanded: boolean): HeadTailCap {
const hidden = total - maxLines
const headLines = Math.ceil(maxLines / 2)
return { hidden, capped: hidden > 0 && !expanded, headLines, tailLines: maxLines - headLines }
}

View File

@@ -24,6 +24,10 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'

View File

@@ -0,0 +1,37 @@
// The copy-to-clipboard-with-feedback hook shared by the block primitives
// (TerminalBlock, SearchBlock): write the given text, and on success flip a
// transient `copied` flag that the caller renders as a "复制成功" label for one
// second. A refused write leaves the flag untouched, so the control never claims
// a copy the host declined.
import { useCallback, useState } from 'react'
import { writeClipboard } from './clipboard.ts'
/** How long the `copied` flag stays true after a successful write, in ms. */
const COPIED_FEEDBACK_MS = 1000
/** The copy-feedback hook's return: the transient flag and the copy handler. */
export interface CopyFeedback {
/** True for {@link COPIED_FEEDBACK_MS} after a successful write; render the success label off it. */
copied: boolean
/** Copy the hook's text; no-op while `copied` is still true, silent on a refused write. */
onCopy: () => void
}
/**
* Copy `text` to the clipboard with one-second success feedback.
* @param text - the text to write on copy.
* @returns the `copied` flag and the `onCopy` handler.
*/
export function useCopyFeedback(text: string): CopyFeedback {
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS)
})
}, [copied, text])
return { copied, onCopy }
}

View File

@@ -0,0 +1,214 @@
// @vitest-environment jsdom
// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the
// 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'
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, with no folded pre-cap total below 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('folds the pre-cap total into the summary when truncated', () => {
const view = render(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
expect(view.getByText('显示 2 / 共 99 处匹配 · 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('folds the pre-cap total into the paths summary when truncated', () => {
const view = render(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
expect(view.getByText('显示 2 / 共 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('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. The tail begins mid-b.ts, so its header is restored —
// and, being a row itself, it consumes one tail slot rather than pushing the
// card to 9 rows: the tail keeps its last 3 matches, total visible = 8.
const view = render(<SearchBlock kind="matches" truncated={false} total={20} maxLines={8} files={[
group('a.ts', 10), group('b.ts', 10, 11),
]} />)
expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10'])
expect(lines(view.container)).toEqual([
'1: hit 1', '2: hit 2', '3: hit 3',
'18: hit 18', '19: hit 19', '20: hit 20',
])
// Visible rows hold at maxLines (2 headers + 6 matches = 8), so the hidden
// count stays exact: 22 8 = 14.
expect(view.getByRole('button', { name: '展开其余 14 行结果' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {
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')
})
})