From 2d42f41113e610764ba8066a668076bdf809a1ba Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 17 Dec 2025 13:35:41 +0800 Subject: [PATCH] refactor: extract tool result views into dedicated registry pattern --- web/src/components/ToolCard/ToolCard.tsx | 54 +- web/src/components/ToolCard/knownTools.tsx | 47 +- .../ToolCard/views/MultiEditView.tsx | 25 +- web/src/components/ToolCard/views/_all.tsx | 5 +- .../components/ToolCard/views/_results.tsx | 584 ++++++++++++++++++ 5 files changed, 657 insertions(+), 58 deletions(-) create mode 100644 web/src/components/ToolCard/views/_results.tsx diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 284a107c..c0b55a3f 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { PermissionFooter } from '@/components/ToolCard/PermissionFooter' import { getToolPresentation } from '@/components/ToolCard/knownTools' import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all' +import { getToolResultViewComponent } from '@/components/ToolCard/views/_results' function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' @@ -23,20 +24,6 @@ function safeStringify(value: unknown): string { } } -function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { - const regex = /(.*?)<\/tool_use_error>/s - const match = message.match(regex) - - if (match) { - return { - isToolUseError: true, - errorMessage: typeof match[1] === 'string' ? match[1].trim() : '' - } - } - - return { isToolUseError: false, errorMessage: null } -} - function getInputString(input: unknown, key: string): string | null { if (!isObject(input)) return null const value = input[key] @@ -251,40 +238,6 @@ function renderToolInput(block: ToolCallBlock): ReactNode { return } -function renderToolResult(block: ToolCallBlock): ReactNode { - const result = block.tool.result - const toolName = block.tool.name - - if (result === undefined || result === null) { - return ( -
- {block.tool.state === 'pending' ? 'Waiting for permission…' : block.tool.state === 'running' ? 'Running…' : '(no output)'} -
- ) - } - - if ((toolName === 'Bash' || toolName === 'CodexBash') && isObject(result)) { - const stdout = typeof result.stdout === 'string' ? result.stdout : null - const stderr = typeof result.stderr === 'string' ? result.stderr : null - if (stdout !== null || stderr !== null) { - return ( -
- {stdout ? : null} - {stderr ? : null} -
- ) - } - } - - if (typeof result === 'string') { - const toolUseError = parseToolUseError(result) - const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result - return - } - - return -} - function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { if (props.state === 'completed') { return ( @@ -358,6 +311,7 @@ export function ToolCard(props: { const showInline = !presentation.minimal && toolName !== 'Task' const CompactToolView = showInline ? getToolViewComponent(toolName) : null const FullToolView = getToolFullViewComponent(toolName) + const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' @@ -421,7 +375,7 @@ export function ToolCard(props: {
Result
- {renderToolResult(props.block)} +
@@ -449,7 +403,7 @@ export function ToolCard(props: {
Result
- {renderToolResult(props.block)} +
) diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 8da45780..ff085075 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -31,6 +31,26 @@ function truncate(text: string, maxLen: number): string { return text.slice(0, maxLen - 3) + '...' } +function countLines(text: string): number { + return text.split('\n').length +} + +function isDocumentFilePath(filePath: string): boolean { + const lower = filePath.toLowerCase() + return ( + lower.endsWith('.md') + || lower.endsWith('.mdx') + || lower.endsWith('.markdown') + || lower.endsWith('.txt') + ) +} + +function shouldCollapseDocumentWrite(filePath: string | null): boolean { + if (!filePath) return false + if (!isDocumentFilePath(filePath)) return false + return true +} + function snakeToTitleWithSpaces(value: string): string { return value .split('_') @@ -147,7 +167,10 @@ export const knownTools: Record { + const file = getInputStringAny(opts.input, ['file_path', 'path']) + return isDocumentFilePath(file ?? '') + } }, MultiEdit: { icon: () => , @@ -159,7 +182,10 @@ export const knownTools: Record 1 ? `${path} (${count} edits)` : path }, - minimal: false + minimal: (opts) => { + const file = getInputStringAny(opts.input, ['file_path', 'path']) + return isDocumentFilePath(file ?? '') + } }, Write: { icon: () => , @@ -167,7 +193,16 @@ export const knownTools: Record { + const content = getInputStringAny(opts.input, ['content', 'text']) + if (!content) return null + const lines = countLines(content) + return lines > 1 ? `${lines} lines` : `${content.length} chars` + }, + minimal: (opts) => { + const file = getInputStringAny(opts.input, ['file_path', 'path']) + return shouldCollapseDocumentWrite(file) + } }, WebFetch: { icon: () => , @@ -270,7 +305,11 @@ export const knownTools: Record { + const unified = getInputStringAny(opts.input, ['unified_diff']) + if (!unified) return true + return unified.length >= 2000 || countLines(unified) >= 50 + } }, ExitPlanMode: { icon: () => , diff --git a/web/src/components/ToolCard/views/MultiEditView.tsx b/web/src/components/ToolCard/views/MultiEditView.tsx index 789a1bfc..4de9f1c0 100644 --- a/web/src/components/ToolCard/views/MultiEditView.tsx +++ b/web/src/components/ToolCard/views/MultiEditView.tsx @@ -7,6 +7,8 @@ function isObject(value: unknown): value is Record { type Edit = { old_string: string; new_string: string } +const MAX_COMPACT_EDITS = 3 + function extractEdits(input: unknown): Edit[] { if (!isObject(input) || !Array.isArray(input.edits)) return [] return input.edits @@ -22,6 +24,28 @@ export function MultiEditView(props: ToolViewProps) { const edits = extractEdits(props.block.tool.input) if (edits.length === 0) return null + return ( +
+ {edits.slice(0, MAX_COMPACT_EDITS).map((edit, idx) => ( + + ))} + {edits.length > MAX_COMPACT_EDITS ? ( +
+ (+{edits.length - MAX_COMPACT_EDITS} more edits) +
+ ) : null} +
+ ) +} + +export function MultiEditFullView(props: ToolViewProps) { + const edits = extractEdits(props.block.tool.input) + if (edits.length === 0) return null + return (
{edits.map((edit, idx) => ( @@ -34,4 +58,3 @@ export function MultiEditView(props: ToolViewProps) {
) } - diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index 47d87a5b..9bc6ae89 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -5,7 +5,7 @@ import { CodexDiffCompactView, CodexDiffFullView } from '@/components/ToolCard/v import { CodexPatchView } from '@/components/ToolCard/views/CodexPatchView' import { EditView } from '@/components/ToolCard/views/EditView' import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView' -import { MultiEditView } from '@/components/ToolCard/views/MultiEditView' +import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView' import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView' import { WriteView } from '@/components/ToolCard/views/WriteView' @@ -28,11 +28,10 @@ export const toolViewRegistry: Record = { export const toolFullViewRegistry: Record = { Edit: EditView, - MultiEdit: MultiEditView, + MultiEdit: MultiEditFullView, Write: WriteView, CodexDiff: CodexDiffFullView, CodexPatch: CodexPatchView, - TodoWrite: TodoWriteView, ExitPlanMode: ExitPlanModeView, exit_plan_mode: ExitPlanModeView } diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx new file mode 100644 index 00000000..8bfe16e8 --- /dev/null +++ b/web/src/components/ToolCard/views/_results.tsx @@ -0,0 +1,584 @@ +import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/views/_all' +import { CodeBlock } from '@/components/CodeBlock' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { basename, resolveDisplayPath } from '@/components/ToolCard/path' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { + const regex = /(.*?)<\/tool_use_error>/s + const match = message.match(regex) + + if (match) { + return { + isToolUseError: true, + errorMessage: typeof match[1] === 'string' ? match[1].trim() : '' + } + } + + return { isToolUseError: false, errorMessage: null } +} + +function extractTextFromContentBlock(block: unknown): string | null { + if (typeof block === 'string') return block + if (!isObject(block)) return null + if (block.type === 'text' && typeof block.text === 'string') return block.text + if (typeof block.text === 'string') return block.text + return null +} + +function extractTextFromResult(result: unknown, depth: number = 0): string | null { + if (depth > 2) return null + if (result === null || result === undefined) return null + if (typeof result === 'string') { + const toolUseError = parseToolUseError(result) + return toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result + } + + if (Array.isArray(result)) { + const parts = result + .map(extractTextFromContentBlock) + .filter((part): part is string => typeof part === 'string' && part.length > 0) + return parts.length > 0 ? parts.join('\n') : null + } + + if (!isObject(result)) return null + + if (typeof result.content === 'string') return result.content + if (typeof result.text === 'string') return result.text + if (typeof result.output === 'string') return result.output + if (typeof result.error === 'string') return result.error + if (typeof result.message === 'string') return result.message + + const contentArray = Array.isArray(result.content) ? result.content : null + if (contentArray) { + const parts = contentArray + .map(extractTextFromContentBlock) + .filter((part): part is string => typeof part === 'string' && part.length > 0) + return parts.length > 0 ? parts.join('\n') : null + } + + const nestedOutput = isObject(result.output) ? result.output : null + if (nestedOutput) { + if (typeof nestedOutput.content === 'string') return nestedOutput.content + if (typeof nestedOutput.text === 'string') return nestedOutput.text + } + + const nestedError = isObject(result.error) ? result.error : null + if (nestedError) { + if (typeof nestedError.message === 'string') return nestedError.message + if (typeof nestedError.error === 'string') return nestedError.error + } + + const nestedResult = isObject(result.result) ? result.result : null + if (nestedResult) { + const nestedText = extractTextFromResult(nestedResult, depth + 1) + if (nestedText) return nestedText + } + + const nestedData = isObject(result.data) ? result.data : null + if (nestedData) { + const nestedText = extractTextFromResult(nestedData, depth + 1) + if (nestedText) return nestedText + } + + return null +} + +function looksLikeHtml(text: string): boolean { + const trimmed = text.trimStart() + return trimmed.startsWith(' + } + + if (opts.mode === 'markdown') { + return + } + + if (looksLikeHtml(text) || looksLikeJson(text)) { + return + } + + return +} + +function placeholderForState(state: ToolViewProps['block']['tool']['state']): string { + if (state === 'pending') return 'Waiting for permission…' + if (state === 'running') return 'Running…' + return '(no output)' +} + +function RawJsonDevOnly(props: { value: unknown }) { + if (!import.meta.env.DEV) return null + if (props.value === null || props.value === undefined) return null + + return ( +
+ + Raw JSON + +
+ +
+
+ ) +} + +function extractStdoutStderr(result: unknown): { stdout: string | null; stderr: string | null } | null { + if (!isObject(result)) return null + + const stdout = typeof result.stdout === 'string' ? result.stdout : null + const stderr = typeof result.stderr === 'string' ? result.stderr : null + if (stdout !== null || stderr !== null) { + return { stdout, stderr } + } + + const nested = isObject(result.output) ? result.output : null + if (nested) { + const nestedStdout = typeof nested.stdout === 'string' ? nested.stdout : null + const nestedStderr = typeof nested.stderr === 'string' ? nested.stderr : null + if (nestedStdout !== null || nestedStderr !== null) { + return { stdout: nestedStdout, stderr: nestedStderr } + } + } + + return null +} + +function extractReadFileContent(result: unknown): { filePath: string | null; content: string } | null { + if (!isObject(result)) return null + const file = isObject(result.file) ? result.file : null + if (!file) return null + + const content = typeof file.content === 'string' ? file.content : null + if (content === null) return null + + const filePath = typeof file.filePath === 'string' + ? file.filePath + : typeof file.file_path === 'string' + ? file.file_path + : null + + return { filePath, content } +} + +function extractLineList(text: string): string[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +function isProbablyMarkdownList(text: string): boolean { + const trimmed = text.trimStart() + return trimmed.startsWith('- ') || trimmed.startsWith('* ') || trimmed.startsWith('1. ') +} + +const BashResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + if (typeof result === 'string') { + const toolUseError = parseToolUseError(result) + const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result + return ( + <> + + + + ) + } + + const stdio = extractStdoutStderr(result) + if (stdio) { + return ( + <> +
+ {stdio.stdout ? : null} + {stdio.stderr ? : null} +
+ + + ) + } + + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'code', language: 'text' })} + + + ) + } + + return ( + <> +
(no output)
+ + + ) +} + +const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'auto' })} + + + ) + } + + return ( + <> +
(no output)
+ + + ) +} + +const LineListResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + const text = extractTextFromResult(result) + if (!text) { + return ( + <> +
(no output)
+ + + ) + } + + if (isProbablyMarkdownList(text)) { + return ( + <> + + + + ) + } + + const lines = extractLineList(text) + if (lines.length === 0) { + return ( + <> +
(no output)
+ + + ) + } + + return ( + <> +
+ {lines.map((line) => ( +
+ {line} +
+ ))} +
+ + + ) +} + +const ReadResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + const file = extractReadFileContent(result) + if (file) { + const path = file.filePath ? resolveDisplayPath(file.filePath, props.metadata) : null + return ( + <> + {path ? ( +
+ {basename(path)} +
+ ) : null} + + + + ) + } + + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'code', language: 'text' })} + + + ) + } + + return ( + <> +
(no output)
+ + + ) +} + +const MutationResultView: ToolViewComponent = (props: ToolViewProps) => { + const { state, result } = props.block.tool + + if (result === undefined || result === null) { + if (state === 'completed') { + return
Done
+ } + return
{placeholderForState(state)}
+ } + + const text = extractTextFromResult(result) + if (typeof text === 'string' && text.trim().length > 0) { + const className = state === 'error' ? 'text-red-600' : 'text-[var(--app-fg)]' + return ( + <> +
+ {renderText(text, { mode: state === 'error' ? 'code' : 'auto' })} +
+ + + ) + } + + return ( + <> +
+ {state === 'completed' ? 'Done' : '(no output)'} +
+ + + ) +} + +const CodexPatchResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'auto' })} + + + ) + } + + if (result === undefined || result === null) { + return props.block.tool.state === 'completed' + ?
Done
+ :
{placeholderForState(props.block.tool.state)}
+ } + + return ( + <> +
(no output)
+ + + ) +} + +const CodexReasoningResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'auto' })} + + + ) + } + + return ( + <> +
(no output)
+ + + ) +} + +const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + if (result === undefined || result === null) { + return props.block.tool.state === 'completed' + ?
Done
+ :
{placeholderForState(props.block.tool.state)}
+ } + + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'code', language: 'diff' })} + + + ) + } + + return ( + <> +
Done
+ + + ) +} + +type TodoItem = { + id?: string + content?: string + status?: 'pending' | 'in_progress' | 'completed' + priority?: 'high' | 'medium' | 'low' +} + +function extractTodos(input: unknown, result: unknown): TodoItem[] { + const todosFromInput = isObject(input) && Array.isArray(input.todos) + ? input.todos.filter(isObject) + : [] + if (todosFromInput.length > 0) { + return todosFromInput.map((t) => ({ + id: typeof t.id === 'string' ? t.id : undefined, + content: typeof t.content === 'string' ? t.content : undefined, + status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined, + priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined + })) + } + + const newTodos = isObject(result) && Array.isArray(result.newTodos) + ? result.newTodos.filter(isObject) + : [] + return newTodos.map((t) => ({ + id: typeof t.id === 'string' ? t.id : undefined, + content: typeof t.content === 'string' ? t.content : undefined, + status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined, + priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined + })) +} + +function todoTone(todo: TodoItem): string { + if (todo.status === 'completed') return 'text-emerald-600 line-through' + if (todo.status === 'in_progress') return 'text-[var(--app-link)]' + return 'text-[var(--app-hint)]' +} + +function todoIcon(todo: TodoItem): string { + if (todo.status === 'completed') return '☑' + return '☐' +} + +const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => { + const todos = extractTodos(props.block.tool.input, props.block.tool.result) + if (todos.length === 0) { + return
{placeholderForState(props.block.tool.state)}
+ } + + return ( +
+ {todos.map((todo, idx) => { + const text = todo.content?.trim() ? todo.content.trim() : '(empty)' + return ( +
+ {todoIcon(todo)} {text} +
+ ) + })} +
+ ) +} + +const GenericResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + const text = extractTextFromResult(result) + if (text) { + return ( + <> + {renderText(text, { mode: 'auto' })} + {typeof result === 'object' ? : null} + + ) + } + + if (typeof result === 'string') { + return renderText(result, { mode: 'auto' }) + } + + return +} + +export const toolResultViewRegistry: Record = { + Task: MarkdownResultView, + Bash: BashResultView, + CodexBash: BashResultView, + Glob: LineListResultView, + Grep: LineListResultView, + LS: LineListResultView, + Read: ReadResultView, + Edit: MutationResultView, + MultiEdit: MutationResultView, + Write: MutationResultView, + WebFetch: MarkdownResultView, + WebSearch: MarkdownResultView, + NotebookRead: ReadResultView, + NotebookEdit: MutationResultView, + TodoWrite: TodoWriteResultView, + CodexReasoning: CodexReasoningResultView, + CodexPatch: CodexPatchResultView, + CodexDiff: CodexDiffResultView, + ExitPlanMode: MarkdownResultView, + exit_plan_mode: MarkdownResultView +} + +export function getToolResultViewComponent(toolName: string): ToolViewComponent { + if (toolName.startsWith('mcp__')) { + return MarkdownResultView + } + return toolResultViewRegistry[toolName] ?? GenericResultView +}