mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor: extract tool result views into dedicated registry pattern
This commit is contained in:
@@ -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<string, unknown> {
|
||||
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>(.*?)<\/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 <CodeBlock code={safeStringify(input)} language="json" />
|
||||
}
|
||||
|
||||
function renderToolResult(block: ToolCallBlock): ReactNode {
|
||||
const result = block.tool.result
|
||||
const toolName = block.tool.name
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--app-hint)]">
|
||||
{block.tool.state === 'pending' ? 'Waiting for permission…' : block.tool.state === 'running' ? 'Running…' : '(no output)'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{stdout ? <CodeBlock code={stdout} language="text" /> : null}
|
||||
{stderr ? <CodeBlock code={stderr} language="text" /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
const toolUseError = parseToolUseError(result)
|
||||
const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result
|
||||
return <CodeBlock code={display} language="text" />
|
||||
}
|
||||
|
||||
return <CodeBlock code={safeStringify(result)} language="json" />
|
||||
}
|
||||
|
||||
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: {
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Result</div>
|
||||
{renderToolResult(props.block)}
|
||||
<ResultToolView block={props.block} metadata={props.metadata} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -449,7 +403,7 @@ export function ToolCard(props: {
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Result</div>
|
||||
{renderToolResult(props.block)}
|
||||
<ResultToolView block={props.block} metadata={props.metadata} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string, {
|
||||
const file = getInputStringAny(opts.input, ['file_path', 'path'])
|
||||
return file ? resolveDisplayPath(file, opts.metadata) : 'Edit file'
|
||||
},
|
||||
minimal: false
|
||||
minimal: (opts) => {
|
||||
const file = getInputStringAny(opts.input, ['file_path', 'path'])
|
||||
return isDocumentFilePath(file ?? '')
|
||||
}
|
||||
},
|
||||
MultiEdit: {
|
||||
icon: () => <FileDiffIcon className={DEFAULT_ICON_CLASS} />,
|
||||
@@ -159,7 +182,10 @@ export const knownTools: Record<string, {
|
||||
const path = resolveDisplayPath(file, opts.metadata)
|
||||
return count > 1 ? `${path} (${count} edits)` : path
|
||||
},
|
||||
minimal: false
|
||||
minimal: (opts) => {
|
||||
const file = getInputStringAny(opts.input, ['file_path', 'path'])
|
||||
return isDocumentFilePath(file ?? '')
|
||||
}
|
||||
},
|
||||
Write: {
|
||||
icon: () => <FileDiffIcon className={DEFAULT_ICON_CLASS} />,
|
||||
@@ -167,7 +193,16 @@ export const knownTools: Record<string, {
|
||||
const file = getInputStringAny(opts.input, ['file_path', 'path'])
|
||||
return file ? resolveDisplayPath(file, opts.metadata) : 'Write file'
|
||||
},
|
||||
minimal: false
|
||||
subtitle: (opts) => {
|
||||
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: () => <GlobeIcon className={DEFAULT_ICON_CLASS} />,
|
||||
@@ -270,7 +305,11 @@ export const knownTools: Record<string, {
|
||||
}
|
||||
return null
|
||||
},
|
||||
minimal: false
|
||||
minimal: (opts) => {
|
||||
const unified = getInputStringAny(opts.input, ['unified_diff'])
|
||||
if (!unified) return true
|
||||
return unified.length >= 2000 || countLines(unified) >= 50
|
||||
}
|
||||
},
|
||||
ExitPlanMode: {
|
||||
icon: () => <ClipboardIcon className={DEFAULT_ICON_CLASS} />,
|
||||
|
||||
@@ -7,6 +7,8 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{edits.slice(0, MAX_COMPACT_EDITS).map((edit, idx) => (
|
||||
<DiffView
|
||||
key={idx}
|
||||
oldString={edit.old_string}
|
||||
newString={edit.new_string}
|
||||
/>
|
||||
))}
|
||||
{edits.length > MAX_COMPACT_EDITS ? (
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
(+{edits.length - MAX_COMPACT_EDITS} more edits)
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiEditFullView(props: ToolViewProps) {
|
||||
const edits = extractEdits(props.block.tool.input)
|
||||
if (edits.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{edits.map((edit, idx) => (
|
||||
@@ -34,4 +58,3 @@ export function MultiEditView(props: ToolViewProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, ToolViewComponent> = {
|
||||
|
||||
export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
|
||||
Edit: EditView,
|
||||
MultiEdit: MultiEditView,
|
||||
MultiEdit: MultiEditFullView,
|
||||
Write: WriteView,
|
||||
CodexDiff: CodexDiffFullView,
|
||||
CodexPatch: CodexPatchView,
|
||||
TodoWrite: TodoWriteView,
|
||||
ExitPlanMode: ExitPlanModeView,
|
||||
exit_plan_mode: ExitPlanModeView
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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>(.*?)<\/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('<!DOCTYPE') || trimmed.startsWith('<html') || trimmed.startsWith('<div') || trimmed.startsWith('<span')
|
||||
}
|
||||
|
||||
function looksLikeJson(text: string): boolean {
|
||||
const trimmed = text.trim()
|
||||
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
}
|
||||
|
||||
function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; language?: string } = { mode: 'auto' }) {
|
||||
if (opts.mode === 'code') {
|
||||
return <CodeBlock code={text} language={opts.language ?? 'text'} />
|
||||
}
|
||||
|
||||
if (opts.mode === 'markdown') {
|
||||
return <MarkdownRenderer content={text} />
|
||||
}
|
||||
|
||||
if (looksLikeHtml(text) || looksLikeJson(text)) {
|
||||
return <CodeBlock code={text} language={looksLikeJson(text) ? 'json' : 'html'} />
|
||||
}
|
||||
|
||||
return <MarkdownRenderer content={text} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-[var(--app-hint)]">
|
||||
Raw JSON
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<CodeBlock code={safeStringify(props.value)} language="json" />
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
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 <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
const toolUseError = parseToolUseError(result)
|
||||
const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result
|
||||
return (
|
||||
<>
|
||||
<CodeBlock code={display} language="text" />
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const stdio = extractStdoutStderr(result)
|
||||
if (stdio) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{stdio.stdout ? <CodeBlock code={stdio.stdout} language="text" /> : null}
|
||||
{stdio.stderr ? <CodeBlock code={stdio.stderr} language="text" /> : null}
|
||||
</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'code', language: 'text' })}
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'auto' })}
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const LineListResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (!text) {
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isProbablyMarkdownList(text)) {
|
||||
return (
|
||||
<>
|
||||
<MarkdownRenderer content={text} />
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const lines = extractLineList(text)
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
{lines.map((line) => (
|
||||
<div key={line} className="text-sm font-mono text-[var(--app-fg)] break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
const file = extractReadFileContent(result)
|
||||
if (file) {
|
||||
const path = file.filePath ? resolveDisplayPath(file.filePath, props.metadata) : null
|
||||
return (
|
||||
<>
|
||||
{path ? (
|
||||
<div className="mb-2 text-xs text-[var(--app-hint)] font-mono break-all">
|
||||
{basename(path)}
|
||||
</div>
|
||||
) : null}
|
||||
<CodeBlock code={file.content} language="text" />
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'code', language: 'text' })}
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MutationResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const { state, result } = props.block.tool
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
if (state === 'completed') {
|
||||
return <div className="text-sm text-[var(--app-hint)]">Done</div>
|
||||
}
|
||||
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(state)}</div>
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
const className = state === 'error' ? 'text-red-600' : 'text-[var(--app-fg)]'
|
||||
return (
|
||||
<>
|
||||
<div className={`text-sm ${className}`}>
|
||||
{renderText(text, { mode: state === 'error' ? 'code' : 'auto' })}
|
||||
</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">
|
||||
{state === 'completed' ? 'Done' : '(no output)'}
|
||||
</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CodexPatchResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'auto' })}
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return props.block.tool.state === 'completed'
|
||||
? <div className="text-sm text-[var(--app-hint)]">Done</div>
|
||||
: <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CodexReasoningResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
if (result === undefined || result === null) {
|
||||
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'auto' })}
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
if (result === undefined || result === null) {
|
||||
return props.block.tool.state === 'completed'
|
||||
? <div className="text-sm text-[var(--app-hint)]">Done</div>
|
||||
: <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'code', language: 'diff' })}
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-[var(--app-hint)]">Done</div>
|
||||
<RawJsonDevOnly value={result} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{todos.map((todo, idx) => {
|
||||
const text = todo.content?.trim() ? todo.content.trim() : '(empty)'
|
||||
return (
|
||||
<div key={todo.id ?? String(idx)} className={`text-sm ${todoTone(todo)}`}>
|
||||
{todoIcon(todo)} {text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {
|
||||
const result = props.block.tool.result
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
|
||||
}
|
||||
|
||||
const text = extractTextFromResult(result)
|
||||
if (text) {
|
||||
return (
|
||||
<>
|
||||
{renderText(text, { mode: 'auto' })}
|
||||
{typeof result === 'object' ? <RawJsonDevOnly value={result} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return renderText(result, { mode: 'auto' })
|
||||
}
|
||||
|
||||
return <CodeBlock code={safeStringify(result)} language="json" />
|
||||
}
|
||||
|
||||
export const toolResultViewRegistry: Record<string, ToolViewComponent> = {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user