import { memo, useMemo, useState } from 'react' import { CircleStop, LoaderCircle } from 'lucide-react' import { CodeViewer } from './CodeViewer' import { DiffViewer } from './DiffViewer' import { TerminalChrome } from './TerminalChrome' import { CopyButton } from '../shared/CopyButton' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' import { InlineImageGallery } from './InlineImageGallery' import type { AgentTaskNotification } from '../../types/chat' import { PlanPreviewCard, extractPlanPreview, isEnterPlanModeTool, isExitPlanModeTool, } from './PlanModePreview' type Props = { toolName: string input: unknown result?: { content: unknown; isError: boolean } | null agentTaskNotification?: AgentTaskNotification compact?: boolean isPending?: boolean status?: 'stopped' partialInput?: string } const TOOL_ICONS: Record = { Bash: 'terminal', Read: 'description', Write: 'edit_document', Edit: 'edit_note', Glob: 'search', Grep: 'find_in_page', Agent: 'smart_toy', WebSearch: 'travel_explore', WebFetch: 'cloud_download', NotebookEdit: 'note', Skill: 'auto_awesome', } const WRITER_PREVIEW_MAX_LINES = 120 const WRITER_PREVIEW_MAX_CHARS = 30000 type ContentStats = { lines: number chars: number visibleLines?: number windowed?: boolean } export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) { const isExitPlanTool = isExitPlanModeTool(toolName) const isEnterPlanTool = isEnterPlanModeTool(toolName) const [expanded, setExpanded] = useState(isExitPlanTool) const t = useTranslation() const obj = input && typeof input === 'object' ? (input as Record) : {} const icon = TOOL_ICONS[toolName] || 'build' const filePath = typeof obj.file_path === 'string' ? obj.file_path : '' const summary = getToolSummary(toolName, obj, t) const outputSummary = getToolResultSummary( toolName, result?.content, result?.isError ?? false, t, ) const pendingSummary = isPending && !result ? getPendingSummary(toolName, t) : '' const stoppedSummary = status === 'stopped' && !result ? t('tool.stopped') : '' const liveStats = useMemo( () => getToolContentStats(toolName, obj, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName], ) const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : '' const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t]) const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t]) const hasResultDetails = Boolean(result && extractTextContent(result.content)) const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string' const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string' const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput) if (isEnterPlanTool) { return ( ) } if (isExitPlanTool) { return ( setExpanded((value) => !value)} /> ) } return (
{expandable && expanded && (
{preview} {details}
)}
) }) function EnterPlanModeToolCallBlock({ result, compact, isPending, }: { result?: { content: unknown; isError: boolean } | null compact: boolean isPending: boolean }) { const t = useTranslation() const errorText = result?.isError ? extractTextContent(result.content) : null return (
architecture {t('settings.permissions.plan')} {isPending ? ( ) : null} {result?.isError ? ( error ) : null}
{result?.isError && errorText ? (
{renderResultOutput(result, errorText, t)}
) : null}
) } function PlanToolCallBlock({ input, result, compact, isPending, expanded, onToggle, }: { input: unknown result?: { content: unknown; isError: boolean } | null compact: boolean isPending: boolean expanded: boolean onToggle: () => void }) { const t = useTranslation() const preview = extractPlanPreview(input, result?.content) const hasPlanPreview = Boolean( preview.plan.trim() || preview.filePath || preview.allowedPrompts.length > 0, ) const showPlanPreview = hasPlanPreview || !result?.isError const title = result?.isError ? t('permission.planRejected') : result ? t('permission.planApproved') : t('permission.planReadyTitle') const hasRawResult = Boolean(result && extractTextContent(result.content)) return (
{expanded ? (
{showPlanPreview ? ( ) : null} {result?.isError && hasRawResult ? ( renderResultOutput(result, extractTextContent(result.content) ?? '', t) ) : null}
) : null}
) } function renderPreview( toolName: string, obj: Record, result?: { content: unknown; isError: boolean } | null, t?: (key: TranslationKey, params?: Record) => string, ) { const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file' const resultText = getVisibleResultText(toolName, result) const resultOutput = result && resultText ? renderResultOutput(result, resultText, t) : null if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') { return ( <> {resultOutput} ) } if (toolName === 'Write' && typeof obj.content === 'string') { return ( <> {resultOutput} ) } if (toolName === 'Bash' && typeof obj.command === 'string') { return ( <>
$ {obj.command}
{resultOutput} ) } if (toolName === 'Read') { return resultOutput } if (resultOutput) return resultOutput return null } function getVisibleResultText( toolName: string, result?: { content: unknown; isError: boolean } | null, ): string | null { if (!result) return null const text = extractTextContent(result.content) if (!text) return null if (result.isError) return text if (toolName === 'Bash' || toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') return null return text } function renderResultOutput( result: { content: unknown; isError: boolean }, text: string, t?: (key: TranslationKey, params?: Record) => string, ) { return ( <>
{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}
{result.isError ? (
            {text}
          
) : ( )}
) } function renderDetails( toolName: string, obj: Record, t?: (key: TranslationKey, params?: Record) => string, partialInput?: string, ) { if (partialInput) { if (toolName === 'Write') { const writerContent = extractPartialJsonStringField(partialInput, 'content') if (writerContent !== null) { return renderWriterPreview(writerContent, t) } } return renderPartialInput(partialInput, t) } if (toolName === 'Edit' || toolName === 'Write') { return null } const text = JSON.stringify(obj, null, 2) return (
{t?.('tool.toolInput') ?? 'Tool Input'}
) } function extractPartialJsonStringField(source: string, field: string): string | null { const key = `"${field}"` const keyIndex = source.indexOf(key) if (keyIndex < 0) return null const colonIndex = source.indexOf(':', keyIndex + key.length) if (colonIndex < 0) return null let index = colonIndex + 1 while (index < source.length && /\s/.test(source[index] ?? '')) index += 1 if (source[index] !== '"') return null index += 1 let value = '' while (index < source.length) { const char = source[index] if (char === '"') return value if (char !== '\\') { value += char index += 1 continue } const escaped = source[index + 1] if (escaped === undefined) break switch (escaped) { case 'n': value += '\n' index += 2 break case 'r': value += '\r' index += 2 break case 't': value += '\t' index += 2 break case 'b': value += '\b' index += 2 break case 'f': value += '\f' index += 2 break case '"': case '\\': case '/': value += escaped index += 2 break case 'u': { const hex = source.slice(index + 2, index + 6) if (/^[0-9a-fA-F]{4}$/.test(hex)) { value += String.fromCharCode(Number.parseInt(hex, 16)) index += 6 } else { index = source.length } break } default: value += escaped index += 2 break } } return value } function getToolContentStats( toolName: string, obj: Record, partialInput?: string, ): ContentStats | null { const content = getToolContentForStats(toolName, obj, partialInput) return content === null ? null : countContentStats(content) } function getToolContentForStats( toolName: string, obj: Record, partialInput?: string, ): string | null { if (toolName === 'Write') { if (typeof obj.content === 'string') return obj.content return partialInput ? extractPartialJsonStringField(partialInput, 'content') : null } if (toolName === 'Edit') { if (typeof obj.new_string === 'string') return obj.new_string return partialInput ? extractPartialJsonStringField(partialInput, 'new_string') : null } if (toolName === 'MultiEdit' && Array.isArray(obj.edits)) { const replacements = obj.edits .map((edit) => ( edit && typeof edit === 'object' && typeof (edit as Record).new_string === 'string' ? (edit as Record).new_string : '' )) .filter(Boolean) return replacements.length > 0 ? replacements.join('\n') : null } return null } function countContentStats(content: string): ContentStats { return { lines: content.length === 0 ? 0 : content.split('\n').length, chars: content.length, } } function formatContentStats( stats: ContentStats, t?: (key: TranslationKey, params?: Record) => string, ): string { const chars = formatCharCount(stats.chars, t) if (stats.windowed && typeof stats.visibleLines === 'number' && stats.visibleLines < stats.lines) { return t?.('tool.contentStatsLatest', { visible: formatCount(stats.visibleLines), total: formatCount(stats.lines), chars, }) ?? `Latest ${formatCount(stats.visibleLines)} / ${formatCount(stats.lines)} lines · ${chars}` } return t?.('tool.contentStats', { lines: formatLineCount(stats.lines, t), chars, }) ?? `${formatLineCount(stats.lines, t)} · ${chars}` } function formatLineCount( count: number, t?: (key: TranslationKey, params?: Record) => string, ): string { return count === 1 ? (t?.('tool.lineCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} line`) : (t?.('tool.lineCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} lines`) } function formatCharCount( count: number, t?: (key: TranslationKey, params?: Record) => string, ): string { return count === 1 ? (t?.('tool.charCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} char`) : (t?.('tool.charCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} chars`) } function formatCount(count: number): string { return new Intl.NumberFormat().format(count) } function renderWriterPreview( content: string, t?: (key: TranslationKey, params?: Record) => string, ) { const contentStats = countContentStats(content) const lines = content.length === 0 ? [] : content.split('\n') const totalLines = contentStats.lines const visibleLines = lines.length > WRITER_PREVIEW_MAX_LINES ? lines.slice(-WRITER_PREVIEW_MAX_LINES) : lines let visibleContent = visibleLines.join('\n') const charTruncated = visibleContent.length > WRITER_PREVIEW_MAX_CHARS if (charTruncated) { visibleContent = visibleContent.slice(-WRITER_PREVIEW_MAX_CHARS) } const lineWindowed = totalLines > visibleLines.length const isWindowed = lineWindowed || charTruncated const visibleLineCount = visibleContent.length === 0 ? 0 : visibleContent.split('\n').length const statsSummary = formatContentStats({ lines: totalLines, chars: contentStats.chars, visibleLines: visibleLineCount, windowed: isWindowed, }, t) return (
{t?.('tool.writerPreview') ?? 'Writer'} {statsSummary}
        {visibleContent}
      
) } function renderPartialInput( partialInput: string, t?: (key: TranslationKey, params?: Record) => string, ) { const formattedInput = formatPartialJsonInput(partialInput) return (
{t?.('tool.partialInput') ?? 'Partial input'}
) } function formatPartialJsonInput(source: string): string { const trimmed = source.trim() if (!trimmed) return source try { return JSON.stringify(JSON.parse(trimmed), null, 2) } catch { return formatJsonLikeInput(trimmed) } } function formatJsonLikeInput(source: string): string { let output = '' let indent = 0 let inString = false let escaping = false let skipWhitespace = false const newline = () => { output = output.trimEnd() output += `\n${' '.repeat(indent)}` skipWhitespace = true } for (const char of source) { if (inString) { output += char if (escaping) { escaping = false } else if (char === '\\') { escaping = true } else if (char === '"') { inString = false } continue } if (skipWhitespace && /\s/.test(char)) continue skipWhitespace = false if (char === '"') { inString = true output += char continue } if (char === '{' || char === '[') { output += char indent += 1 newline() continue } if (char === '}' || char === ']') { indent = Math.max(0, indent - 1) if (!output.endsWith('\n')) newline() output += char continue } if (char === ',') { output += char newline() continue } if (char === ':') { output += ': ' skipWhitespace = true continue } output += char } return output.trimEnd() } function getPendingSummary( toolName: string, t?: (key: TranslationKey, params?: Record) => string, ): string { if (toolName === 'Write') return t?.('tool.generatingContent') ?? 'Generating content' if (toolName === 'Edit' || toolName === 'MultiEdit') return t?.('tool.preparingEdit') ?? 'Preparing edit' return t?.('tool.preparingTool') ?? 'Preparing tool' } function getToolResultSummary( toolName: string, content: unknown, isError: boolean, t?: (key: TranslationKey, params?: Record) => string, ): string { const text = extractTextContent(content) if (!text) return '' if (isError) { const firstLine = text .split('\n') .map((line) => stripAnsi(line).replace(/\s+/g, ' ').trim()) .find(Boolean) if (!firstLine) { return t?.('tool.error') ?? 'Error' } return firstLine.length <= 72 ? firstLine : `${firstLine.slice(0, 72)}…` } if (toolName === 'Bash') return '' const lineCount = text.split('\n').length if (lineCount > 1) { return t?.('tool.linesOutput', { count: lineCount }) ?? `${lineCount} lines output` } const compact = text.replace(/\s+/g, ' ').trim() if (!compact) return '' if (compact.length <= 36) return compact return `${compact.slice(0, 36)}…` } function stripAnsi(value: string): string { return value.replace(/\x1B\[[0-9;]*m/g, '') } function getToolSummary(toolName: string, obj: Record, t?: (key: TranslationKey, params?: Record) => string): string { switch (toolName) { case 'Bash': return typeof obj.command === 'string' ? obj.command : '' case 'Read': return t?.('tool.readFileContents') ?? 'Read file contents' case 'Write': return typeof obj.content === 'string' ? (t?.('tool.linesCreated', { count: obj.content.split('\n').length }) ?? `${obj.content.split('\n').length} lines created`) : (t?.('tool.createFile') ?? 'Create file') case 'Edit': return typeof obj.old_string === 'string' && typeof obj.new_string === 'string' ? changedLineSummary(obj.old_string, obj.new_string, t) : (t?.('tool.updateFileContents') ?? 'Update file contents') case 'Glob': return typeof obj.pattern === 'string' ? obj.pattern : '' case 'Grep': return typeof obj.pattern === 'string' ? obj.pattern : '' case 'Agent': return typeof obj.description === 'string' ? obj.description : '' default: return '' } } function extractTextContent(content: unknown): string | null { if (typeof content === 'string') return content if (Array.isArray(content)) { return content .map((chunk: any) => (typeof chunk === 'string' ? chunk : chunk?.text || '')) .filter(Boolean) .join('\n') } if (content && typeof content === 'object') { return JSON.stringify(content, null, 2) } return null } function changedLineSummary(oldString: string, newString: string, t?: (key: TranslationKey, params?: Record) => string): string { const oldLines = oldString.split('\n') const newLines = newString.split('\n') let changed = 0 const max = Math.max(oldLines.length, newLines.length) for (let index = 0; index < max; index += 1) { if ((oldLines[index] ?? '') !== (newLines[index] ?? '')) { changed += 1 } } return t?.('tool.linesChanged', { count: changed }) ?? `${changed} lines changed` }