import React from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk'; import { toolDisplayStyles } from '@/lib/typography'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { renderListOutput, renderGrepOutput, renderGlobOutput, renderTodoOutput, renderWebSearchOutput, parseDiffToUnified, formatEditOutput, detectLanguageFromOutput, formatInputForDisplay, hasLspDiagnostics, } from '../toolRenderers'; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; interface ToolPartProps { part: ToolPartType; isExpanded: boolean; onToggle: (toolId: string) => void; syntaxTheme: { [key: string]: React.CSSProperties }; isMobile: boolean; onContentChange?: (reason?: ContentChangeReason) => void; hasPrevTool?: boolean; hasNextTool?: boolean; } // eslint-disable-next-line react-refresh/only-export-components export const getToolIcon = (toolName: string) => { const iconClass = 'h-3.5 w-3.5 flex-shrink-0'; const tool = toolName.toLowerCase(); if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') { return ; } if (tool === 'write' || tool === 'create' || tool === 'file_write') { return ; } if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') { return ; } if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') { return ; } if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') { return ; } if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') { return ; } if (tool === 'glob') { return ; } if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') { return ; } if ( tool === 'web-search' || tool === 'websearch' || tool === 'search_web' || tool === 'codesearch' || tool === 'google' || tool === 'bing' || tool === 'duckduckgo' || tool === 'perplexity' ) { return ; } if (tool === 'todowrite' || tool === 'todoread') { return ; } if (tool.startsWith('git')) { return ; } return ; }; const formatDuration = (start: number, end?: number, now: number = Date.now()) => { const duration = end ? end - start : now - start; const seconds = duration / 1000; const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds; return `${displaySeconds.toFixed(1)}s`; }; const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => { const [now, setNow] = React.useState(() => Date.now()); React.useEffect(() => { if (!active) { return; } const timer = window.setInterval(() => { setNow(Date.now()); }, 100); return () => window.clearInterval(timer); }, [active]); return <>{formatDuration(start, end, now)}; }; const parseDiffStats = (metadata?: Record): { added: number; removed: number } | null => { if (!metadata?.diff || typeof metadata.diff !== 'string') return null; const lines = metadata.diff.split('\n'); let added = 0; let removed = 0; for (const line of lines) { if (line.startsWith('+') && !line.startsWith('+++')) added++; if (line.startsWith('-') && !line.startsWith('---')) removed++; } if (added === 0 && removed === 0) return null; return { added, removed }; }; const getRelativePath = (absolutePath: string, currentDirectory: string, isMobile: boolean): string => { if (isMobile) { return absolutePath.split('/').pop() || absolutePath; } if (absolutePath.startsWith(currentDirectory)) { const relativePath = absolutePath.substring(currentDirectory.length); return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; } return absolutePath; }; const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: boolean, currentDirectory: string): string => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; if ((part.tool === 'edit' || part.tool === 'multiedit') && input) { const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory, isMobile); } } if ((part.tool === 'read' || part.tool === 'write') && input) { const filePath = input?.filePath || input?.file_path || input?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory, isMobile); } } if (part.tool === 'bash' && input?.command && typeof input.command === 'string') { const firstLine = input.command.split('\n')[0]; return isMobile ? firstLine.substring(0, 50) : firstLine.substring(0, 100); } if (part.tool === 'task' && input?.description && typeof input.description === 'string') { return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80); } const desc = input?.description || metadata?.description || ('title' in state && state.title) || ''; return typeof desc === 'string' ? desc : ''; }; interface ToolScrollableSectionProps { children: React.ReactNode; maxHeightClass?: string; className?: string; outerClassName?: string; disableHorizontal?: boolean; } const ToolScrollableSection: React.FC = ({ children, maxHeightClass = 'max-h-[60vh]', className, outerClassName, disableHorizontal = false, }) => (
{children}
); type TaskToolSummaryEntry = { id?: string; tool?: string; state?: { status?: string; title?: string; }; }; const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { const title = entry.state?.title; if (typeof title === 'string' && title.trim().length > 0) { return title; } if (typeof entry.tool === 'string' && entry.tool.trim().length > 0) { return entry.tool; } return 'tool'; }; const stripTaskMetadataFromOutput = (output: string): string => { // OpenCode appends a non-user-facing session marker for task tools. // Strip only a trailing ... block. return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); }; const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; hasPrevTool: boolean; hasNextTool: boolean; output?: string; }> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output }) => { const completedEntries = React.useMemo(() => { return entries.filter((entry) => entry.state?.status === 'completed'); }, [entries]); const trimmedOutput = typeof output === 'string' ? stripTaskMetadataFromOutput(output) : ''; const hasOutput = trimmedOutput.length > 0; const [isOutputExpanded, setIsOutputExpanded] = React.useState(false); if (completedEntries.length === 0 && !hasOutput) { return null; } const visibleEntries = isExpanded ? completedEntries : completedEntries.slice(-6); const hiddenCount = Math.max(0, completedEntries.length - visibleEntries.length); return (
{completedEntries.length > 0 ? (
{hiddenCount > 0 ? (
+{hiddenCount} more…
) : null} {visibleEntries.map((entry, idx) => { const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool'; const label = getTaskSummaryLabel(entry); const displayName = getToolMetadata(toolName).displayName; return (
{getToolIcon(toolName)} {displayName} {label}
); })}
) : null} {hasOutput ? (
0 && 'pt-1')} > {isOutputExpanded ? (
) : null}
) : null}
); }; interface DiffPreviewProps { diff: string; syntaxTheme: { [key: string]: React.CSSProperties }; input?: ToolStateWithMetadata['input']; } const DiffPreview: React.FC = ({ diff, syntaxTheme, input }) => (
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
{`${hunk.file} (line ${hunk.oldStart})`}
{hunk.lines.map((line, lineIdx) => (
{line.lineNumber || ''}
{line.content}
))}
))}
); interface WriteInputPreviewProps { content: string; syntaxTheme: { [key: string]: React.CSSProperties }; filePath?: string; displayPath: string; } const WriteInputPreview: React.FC = ({ content, syntaxTheme, filePath, displayPath }) => { const lines = content.split('\n'); const language = getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined); const lineCount = Math.max(lines.length, 1); const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; return (
{`${displayPath} (${headerLineLabel})`}
{lines.map((line, lineIdx) => (
{lineIdx + 1}
{line || ' '}
))}
); }; interface ToolExpandedContentProps { part: ToolPartType; state: ToolStateUnion; syntaxTheme: { [key: string]: React.CSSProperties }; isMobile: boolean; currentDirectory: string; hasPrevTool: boolean; hasNextTool: boolean; } const ToolExpandedContent: React.FC = ({ part, state, syntaxTheme, isMobile, currentDirectory, hasPrevTool, hasNextTool, }) => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const rawOutput = stateWithData.output; const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0; const outputString = typeof rawOutput === 'string' ? rawOutput : ''; const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null; const writeFilePath = part.tool === 'write' ? typeof input?.filePath === 'string' ? input.filePath : typeof input?.file_path === 'string' ? input.file_path : typeof input?.path === 'string' ? input.path : undefined : undefined; const writeInputContent = part.tool === 'write' ? typeof (input as { content?: unknown })?.content === 'string' ? (input as { content?: string }).content : typeof (input as { text?: unknown })?.text === 'string' ? (input as { text?: string }).text : null : null; const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent; const writeDisplayPath = shouldShowWriteInputPreview ? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file') : null; const inputTextContent = React.useMemo(() => { if (!input || typeof input !== 'object' || Object.keys(input).length === 0) { return ''; } if ('command' in input && typeof input.command === 'string' && part.tool === 'bash') { return formatInputForDisplay(input, part.tool); } if (typeof (input as { content?: unknown }).content === 'string') { return (input as { content?: string }).content ?? ''; } return formatInputForDisplay(input, part.tool); }, [input, part.tool]); const hasInputText = inputTextContent.trim().length > 0; const renderScrollableBlock = ( content: React.ReactNode, options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string } ) => ( {content} ); const renderResultContent = () => { if (part.tool === 'todowrite' || part.tool === 'todoread') { if (state.status === 'completed' && hasStringOutput) { const todoContent = renderTodoOutput(outputString, { unstyled: true }); return renderScrollableBlock( todoContent ?? (
Unable to parse todo list
) ); } if (state.status === 'error' && 'error' in state) { return (
Error:
{state.error}
); } return
Processing todo list...
; } if (part.tool === 'list' && hasStringOutput) { const listOutput = renderListOutput(outputString, { unstyled: true }); return renderScrollableBlock( listOutput ?? (
                        {outputString}
                    
) ); } if (part.tool === 'grep' && hasStringOutput) { const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true }); return renderScrollableBlock( grepOutput ?? (
                        {outputString}
                    
) ); } if (part.tool === 'glob' && hasStringOutput) { const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true }); return renderScrollableBlock( globOutput ?? (
                        {outputString}
                    
) ); } if (part.tool === 'task' && hasStringOutput) { return renderScrollableBlock(
); } if ((part.tool === 'web-search' || part.tool === 'websearch' || part.tool === 'search_web') && hasStringOutput) { const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true }); return renderScrollableBlock( webSearchContent ?? (
                        {outputString}
                    
) ); } if (part.tool === 'codesearch' && hasStringOutput) { return renderScrollableBlock(
); } if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) { return renderScrollableBlock( , { className: 'p-1' } ); } if (hasStringOutput && outputString.trim()) { if (part.tool === 'read') { const formattedOutput = formatEditOutput(outputString, part.tool, metadata); const lines = formattedOutput.split('\n'); const offset = typeof input?.offset === 'number' ? input.offset : 0; const limit = typeof input?.limit === 'number' ? input.limit : undefined; const isInfoMessage = (line: string) => line.trim().startsWith('('); return renderScrollableBlock(
{lines.map((line: string, idx: number) => { const isInfo = isInfoMessage(line); const lineNumber = offset + idx + 1; const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit); return (
{shouldShowLineNumber ? lineNumber : ''}
{isInfo ? (
{line}
) : ( )} PreTag="div" wrapLines wrapLongLines customStyle={{ margin: 0, padding: 0, fontSize: 'inherit', background: 'transparent', backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', wordBreak: 'break-all', overflowWrap: 'anywhere', }} codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', }, }} > {line} )}
); })}
, { className: 'p-1' } ); } return renderScrollableBlock( {formatEditOutput(outputString, part.tool, metadata)} , { className: 'p-1' } ); } return renderScrollableBlock(
No output produced
, { maxHeightClass: 'max-h-60' } ); }; return (
{(part.tool === 'todowrite' || part.tool === 'todoread') ? ( renderResultContent() ) : ( <> {shouldShowWriteInputPreview ? (
{renderScrollableBlock( )}
) : hasInputText ? (
{renderScrollableBlock(
{inputTextContent}
, { maxHeightClass: 'max-h-60', className: 'tool-input-surface' } )}
) : null} {part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
Result:
{renderResultContent()}
)} {state.status === 'error' && 'error' in state && (
Error:
{state.error}
)} )}
); }; const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => { const state = part.state; const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const isTaskTool = part.tool.toLowerCase() === 'task'; const isFinalized = state.status === 'completed' || state.status === 'error'; const isActive = state.status === 'running' || state.status === 'pending'; const isError = state.status === 'error'; const previousExpandedRef = React.useRef(isExpanded); React.useEffect(() => { if (!isFinalized && !isTaskTool) { return; } if (previousExpandedRef.current === isExpanded) { return; } previousExpandedRef.current = isExpanded; if (typeof isExpanded === 'boolean') { onContentChange?.('structural'); } }, [isExpanded, isFinalized, isTaskTool, onContentChange]); const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const time = stateWithData.time; // Pin start/end so a server-side time reset doesn't reset UI duration. const pinnedTaskTimeRef = React.useRef<{ start?: number; end?: number }>({}); const lastPinnedTaskIdRef = React.useRef(part.id); if (lastPinnedTaskIdRef.current !== part.id) { lastPinnedTaskIdRef.current = part.id; pinnedTaskTimeRef.current = {}; } if (isTaskTool) { if (typeof time?.start === 'number') { const pinnedStart = pinnedTaskTimeRef.current.start; if (typeof pinnedStart !== 'number' || time.start < pinnedStart) { pinnedTaskTimeRef.current.start = time.start; } } if (typeof time?.end === 'number') { pinnedTaskTimeRef.current.end = time.end; } } const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start; const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end; const taskSummaryEntries = React.useMemo(() => { if (!isTaskTool) { return []; } const candidate = (metadata as { summary?: unknown } | undefined)?.summary; if (!Array.isArray(candidate)) { return []; } return candidate.filter((entry): entry is TaskToolSummaryEntry => typeof entry === 'object' && entry !== null) as TaskToolSummaryEntry[]; }, [isTaskTool, metadata]); const taskSummaryLenRef = React.useRef(taskSummaryEntries.length); React.useEffect(() => { if (!isTaskTool) { return; } if (taskSummaryLenRef.current === taskSummaryEntries.length) { return; } taskSummaryLenRef.current = taskSummaryEntries.length; onContentChange?.('structural'); }, [isTaskTool, onContentChange, taskSummaryEntries.length]); const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null; const description = getToolDescription(part, state, isMobile, currentDirectory); const displayName = getToolMetadata(part.tool).displayName; const runtime = React.useContext(RuntimeAPIContext); const handleMainClick = (e: React.MouseEvent) => { if (isTaskTool || !runtime?.editor) { onToggle(part.id); return; } let filePath: unknown; if (part.tool === 'edit' || part.tool === 'multiedit') { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; } else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; } if (typeof filePath === 'string') { e.stopPropagation(); let absolutePath = filePath; if (!filePath.startsWith('/')) { absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath; } runtime.editor.openFile(absolutePath); } else { onToggle(part.id); } }; if (!isFinalized && !isTaskTool) { return null; } return (
{}
{}
{ e.stopPropagation(); onToggle(part.id); }}> {}
{getToolIcon(part.tool)}
{}
{isExpanded ? : }
{displayName}
{description && ( {description} )} {diffStats && ( +{diffStats.added} {' '} -{diffStats.removed} )} {typeof effectiveTimeStart === 'number' && ( )}
{} {isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized) ? ( ) : null} {!isTaskTool && isExpanded ? ( ) : null}
); }; export default ToolPart;