# Conflicts: # apps/web/tests/navigation-panes.e2e.ts # apps/web/tests/snapshots/navigation-panes/details-open.expected.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx # packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
204 lines
7.8 KiB
TypeScript
204 lines
7.8 KiB
TypeScript
/**
|
|
* Pure row-model derivation for tool summary rows: variant classification,
|
|
* one-line summary and expanded-body text from the frozen call slice. This
|
|
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
|
* terminal card gets its expanded body from the views instead, through
|
|
* `terminalCardModel` in terminal-card-model.ts.
|
|
*/
|
|
// The block union's defining home is runtime (fold-product types); this
|
|
// contract only forwards it (type-definition authority stays with the layer
|
|
// that produces the values).
|
|
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
/** The frozen slice the chat view hands to toolview components as `block`
|
|
* (both members are cache-stable references off ConversationSnapshot). */
|
|
|
|
/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
|
|
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
|
|
|
|
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
|
|
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
|
|
|
|
/** Figma row titles per variant (design literals, not translatable copy). */
|
|
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
|
|
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
|
|
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
|
|
}
|
|
|
|
/** Known tool name -> variant. */
|
|
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
|
|
bash: 'bash',
|
|
read: 'read',
|
|
web_fetch: 'read',
|
|
web_search: 'search',
|
|
grep: 'search',
|
|
glob: 'search',
|
|
write: 'write',
|
|
edit: 'edit',
|
|
run_code: 'code',
|
|
cordis_inspect: 'read',
|
|
cordis_mount: 'code',
|
|
cordis_unmount: 'others',
|
|
}
|
|
|
|
/** Tool-owned titles that refine a generic row variant without replacing it. */
|
|
const TOOL_TITLES: Record<string, string> = {
|
|
cordis_inspect: 'Inspect',
|
|
cordis_mount: 'Mount temporary Plugin',
|
|
cordis_unmount: 'Unmount temporary Plugin',
|
|
}
|
|
|
|
/**
|
|
* Classify a tool name into its row variant.
|
|
* @param toolName - wire tool name.
|
|
* @returns matching variant, others when unknown.
|
|
*/
|
|
export function classifyTool(toolName: string): ToolRowVariant {
|
|
return TOOL_VARIANTS[toolName] ?? 'others'
|
|
}
|
|
|
|
/** Everything ToolRow needs, derived once from the frozen slice. */
|
|
export interface ToolRowModel {
|
|
variant: ToolRowVariant
|
|
title: string
|
|
summary: string
|
|
/**
|
|
* Filesystem path from args (`path` / `file_path`) when the row is a file
|
|
* tool; absent for URL reads and non-file tools. The chat view resolves
|
|
* relative values against the session cwd before opening.
|
|
*/
|
|
filePath: string | undefined
|
|
/** Expanded-body text (pretty args); null = row not expandable. */
|
|
body: string | null
|
|
state: ToolRowState
|
|
}
|
|
|
|
function parseArgs(argsRaw: string): unknown {
|
|
try {
|
|
return JSON.parse(argsRaw)
|
|
} catch {
|
|
// Non-JSON args (mid-stream truncation): summary/body fall back to the raw string.
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function firstLine(text: string): string {
|
|
const nl = text.indexOf('\n')
|
|
return nl === -1 ? text : text.slice(0, nl)
|
|
}
|
|
|
|
function pickString(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
for (const key of keys) {
|
|
const v = args[key]
|
|
if (typeof v === 'string' && v !== '') return v
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Summary key preference per variant (args-derived; result-derived summaries are a ledger item). */
|
|
const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
|
bash: ['description', 'command'],
|
|
read: ['path', 'file_path', 'url'],
|
|
search: ['query', 'pattern', 'url'],
|
|
think: [],
|
|
write: ['path', 'file_path'],
|
|
edit: ['path', 'file_path'],
|
|
code: ['description'],
|
|
others: [],
|
|
}
|
|
|
|
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
|
|
function relativizeToCwd(text: string, cwd: string | undefined): string {
|
|
if (cwd === undefined || cwd === '') return text
|
|
const root = cwd.replace(/[/\\]+$/, '')
|
|
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
|
|
return text
|
|
}
|
|
|
|
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
|
const parsed = parseArgs(argsRaw)
|
|
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
|
|
const args = parsed as Record<string, unknown>
|
|
const picked = pickString(args, SUMMARY_KEYS[variant])
|
|
if (picked !== undefined) return firstLine(picked)
|
|
for (const v of Object.values(args)) {
|
|
if (typeof v === 'string' && v !== '') return firstLine(v)
|
|
}
|
|
return firstLine(argsRaw)
|
|
}
|
|
|
|
/** Path keys only — never `url` (web_fetch lands on the read variant). */
|
|
const FILE_PATH_KEYS = ['path', 'file_path'] as const
|
|
|
|
/** File-tool variants whose summary may be an openable workspace path. */
|
|
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
|
|
|
|
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
|
|
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
|
|
const parsed = parseArgs(argsRaw)
|
|
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
|
|
return picked === undefined ? undefined : firstLine(picked)
|
|
}
|
|
|
|
/**
|
|
* Resolve a tool-arg path against the session cwd for host.openPath.
|
|
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
|
|
* @param cwd - session working directory (may be absent for ungrouped sessions).
|
|
* @param path - path as carried in tool args.
|
|
* @returns a host-facing path string.
|
|
*/
|
|
export function resolveToolPath(cwd: string | undefined, path: string): string {
|
|
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
|
if (cwd === undefined || cwd === '') return path
|
|
const base = cwd.replace(/[/\\]+$/, '')
|
|
const rel = path.replace(/^[/\\]+/, '')
|
|
return `${base}/${rel}`
|
|
}
|
|
|
|
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
|
if (argsRaw === '') return null
|
|
const parsed = parseArgs(argsRaw)
|
|
if (parsed === undefined) return argsRaw
|
|
// The code row's expanded body IS the program (monospace via the row's
|
|
// variant styling), not the args JSON envelope around it.
|
|
if (variant === 'code' && typeof parsed === 'object' && parsed !== null) {
|
|
const code = (parsed as Record<string, unknown>).code
|
|
if (typeof code === 'string' && code !== '') return code
|
|
}
|
|
return JSON.stringify(parsed, null, 2)
|
|
}
|
|
|
|
/**
|
|
* Derive the full row model from a frozen call slice.
|
|
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
|
|
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
|
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
|
|
* @returns the row model.
|
|
*/
|
|
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
|
|
const variant = classifyTool(toolName)
|
|
const done = 'kind' in block
|
|
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
|
|
const state: ToolRowState = !done ? 'running'
|
|
: block.error?.code === 'interrupted' ? 'stopped'
|
|
: block.isError ? 'error' : 'ok'
|
|
const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
|
|
const toolTitle = TOOL_TITLES[toolName]
|
|
// Others keeps the static "Tool call" title (figma literal); the real tool
|
|
// name rides the mutable summary slot unless the tool owns a specific title.
|
|
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
|
|
? `${toolName} · ${base}`
|
|
: base
|
|
return {
|
|
variant,
|
|
title: toolTitle ?? VARIANT_TITLES[variant],
|
|
summary,
|
|
filePath: deriveFilePath(variant, argsRaw),
|
|
body: deriveBody(variant, argsRaw),
|
|
state,
|
|
}
|
|
}
|