import React from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { File as PierreFile, PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; import { formatTimestampForDisplay } from '../timeFormat'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { opencodeClient } from '@/lib/opencode/client'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import type { ToolPopupContent } from '../types'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { renderListOutput, renderGrepOutput, renderGlobOutput, renderTodoOutput, renderWebSearchOutput, formatEditOutput, detectLanguageFromOutput, formatInputForDisplay, parseReadToolOutput, } from '../toolRenderers'; import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle'; import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock'; 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; onShowPopup?: (content: ToolPopupContent) => 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 === 'apply_patch' || 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 === 'structuredoutput' || tool === 'structured_output') { return ; } if (tool === 'skill') { return ; } if (tool === 'task') { return ; } if (tool === 'question') { return ; } if (tool === 'plan_enter') { return ; } if (tool === 'plan_exit') { 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): string => { if (absolutePath.startsWith(currentDirectory)) { const relativePath = absolutePath.substring(currentDirectory.length); return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; } return absolutePath; }; const usePierreThemeConfig = () => { const themeSystem = useOptionalThemeSystem(); const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []); const availableThemes = React.useMemo( () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme], [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes], ); const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id; const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id; const lightTheme = React.useMemo( () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme, [availableThemes, fallbackLightTheme, lightThemeId], ); const darkTheme = React.useMemo( () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme, [availableThemes, darkThemeId, fallbackDarkTheme], ); React.useEffect(() => { ensurePierreThemeRegistered(lightTheme); ensurePierreThemeRegistered(darkTheme); }, [darkTheme, lightTheme]); const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light'; return { pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id }, pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const), }; }; // Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..." const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => { const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s); if (!match) return null; const pairs: Array<{ question: string; answer: string }> = []; const content = match[1]; // Match "question"="answer" pairs, handling multiline answers const pairRegex = /"([^"]+)"="([^"]*(?:[^"\\]|\\.)*)"/g; let pairMatch; while ((pairMatch = pairRegex.exec(content)) !== null) { pairs.push({ question: pairMatch[1], answer: pairMatch[2], }); } return pairs.length > 0 ? pairs : null; }; const formatStructuredOutputDescription = (input: Record | undefined, output: unknown): string => { if (typeof output === 'string' && output.trim().length > 0) { const maxLength = 100; const text = output.trim(); return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; } if (!input || typeof input !== 'object') { return 'Result'; } const rawValue = Object.prototype.hasOwnProperty.call(input, 'result') ? input.result : input; const toPreview = (value: unknown): string => { if (typeof value === 'string') { return value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } if (Array.isArray(value)) { const joined = value .map((item) => (typeof item === 'string' ? item : JSON.stringify(item))) .join(', '); return joined; } if (value && typeof value === 'object') { const record = value as Record; if (typeof record.subject === 'string' && record.subject.trim().length > 0) { return record.subject; } if (typeof record.title === 'string' && record.title.trim().length > 0) { return record.title; } return JSON.stringify(value); } return ''; }; const preview = toPreview(rawValue).trim(); if (!preview) { return 'Result'; } const maxLength = 100; const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview; return truncated; }; const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; if (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; const filePath = firstFile?.relativePath || firstFile?.filePath; if (files.length > 1) return null; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } return null; } 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); } } if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool) && input) { const filePath = input?.filePath || input?.file_path || input?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } } return null; }; const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const tool = part.tool.toLowerCase(); if (tool === 'structuredoutput' || tool === 'structured_output') { return formatStructuredOutputDescription(input, stateWithData.output); } const filePathLabel = getToolDescriptionPath(part, state, currentDirectory); if (filePathLabel) { return filePathLabel; } if (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; if (files.length > 1) { return `${files.length} files`; } return 'Patch'; } // Question tool: show "Asked N question(s)" if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) { const count = input.questions.length; return `Asked ${count} question${count !== 1 ? 's' : ''}`; } if (part.tool === 'bash' && input?.command && typeof input.command === 'string') { const firstLine = input.command.split('\n')[0]; return firstLine.substring(0, 100); } if (part.tool === 'task' && input?.description && typeof input.description === 'string') { return input.description.substring(0, 80); } if (part.tool === 'skill' && input?.name && typeof input.name === 'string') { return input.name; } if (part.tool === 'plan_enter') { return 'Switching to planning'; } if (part.tool === 'plan_exit') { return 'Switching to building'; } 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; }; }; type SessionMessageWithParts = { info?: { role?: string; }; parts?: Array<{ id?: string; type?: string; tool?: string; state?: { status?: string; title?: string; }; }>; }; const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = []; const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => { if (typeof output !== 'string' || output.trim().length === 0) { return undefined; } const parsedMetadata = parseTaskMetadataBlock(output); if (parsedMetadata.sessionId) { return parsedMetadata.sessionId; } const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/); const candidate = match?.[1]; return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined; }; const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => { const entries: TaskToolSummaryEntry[] = []; for (const message of messages) { if (message?.info?.role !== 'assistant') { continue; } const parts = Array.isArray(message.parts) ? message.parts : []; for (const part of parts) { if (part?.type !== 'tool') { continue; } const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : ''; if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') { continue; } entries.push({ id: part.id, tool: part.tool, state: { status: part.state?.status, title: part.state?.title, }, }); } } return entries; }; 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 FILE_PATH_LABEL_TOOLS = new Set([ 'read', 'view', 'file_read', 'cat', 'write', 'create', 'file_write', 'edit', 'multiedit', 'apply_patch', ]); const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => { if (!FILE_PATH_LABEL_TOOLS.has(toolName.toLowerCase())) { return false; } const trimmed = label.trim(); if (!trimmed || trimmed === 'Patch' || /^\d+\s+files$/.test(trimmed)) { return false; } return trimmed.includes('/') || trimmed.includes('\\'); }; const stripTaskMetadataFromOutput = (output: string): string => { // Strip only a trailing ... block. return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); }; const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => { if (!Array.isArray(value)) { return []; } const normalized: TaskToolSummaryEntry[] = []; for (const entry of value) { if (typeof entry === 'string') { normalized.push({ tool: 'tool', state: { status: 'completed', title: entry }, }); continue; } if (!entry || typeof entry !== 'object') { continue; } const record = entry as { id?: unknown; tool?: unknown; title?: unknown; status?: unknown; state?: { status?: unknown; title?: unknown }; }; const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined; const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined; const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined); const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined); normalized.push({ id: typeof record.id === 'string' ? record.id : undefined, tool: typeof record.tool === 'string' ? record.tool : 'tool', state: { status, title, }, }); } return normalized; }; const parseTaskMetadataBlock = (output: string | undefined): { sessionId?: string; summaryEntries: TaskToolSummaryEntry[]; } => { if (typeof output !== 'string' || output.trim().length === 0) { return { summaryEntries: [] }; } const blockMatch = output.match(/\s*([\s\S]*?)\s*<\/task_metadata>/i); if (!blockMatch?.[1]) { return { summaryEntries: [] }; } const raw = blockMatch[1].trim(); if (!raw) { return { summaryEntries: [] }; } try { const parsed = JSON.parse(raw) as { sessionId?: unknown; sessionID?: unknown; summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown; }; const summaryEntries = normalizeTaskSummaryEntries( parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls ); const sessionId = (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0 ? parsed.sessionId.trim() : undefined) ?? (typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0 ? parsed.sessionID.trim() : undefined); return { sessionId, summaryEntries }; } catch { return { summaryEntries: [] }; } }; const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; isMobile: boolean; hasPrevTool: boolean; hasNextTool: boolean; output?: string; sessionId?: string; onShowPopup?: (content: ToolPopupContent) => void; input?: Record; }> = ({ entries, isExpanded, isMobile, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => { const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const displayEntries = React.useMemo(() => { const nonPending = entries.filter((entry) => entry.state?.status !== 'pending'); return nonPending.length > 0 ? nonPending : entries; }, [entries]); const trimmedOutput = typeof output === 'string' ? stripTaskMetadataFromOutput(output) : ''; const hasOutput = trimmedOutput.length > 0; const [isOutputExpanded, setIsOutputExpanded] = React.useState(false); const handleOpenSession = (event: React.MouseEvent) => { event.stopPropagation(); if (sessionId) { setCurrentSession(sessionId); } }; const agentType = typeof input?.subagent_type === 'string' ? input.subagent_type : 'subagent'; if (displayEntries.length === 0 && !hasOutput && !sessionId) { return null; } const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6); const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length); return (
{displayEntries.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 status = entry.state?.status; const displayName = getToolMetadata(toolName).displayName; return (
{getToolIcon(toolName)} {displayName} {status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( renderPathLikeGitChanges(label) ) : ( {label} )}
); })}
) : null} {sessionId && ( )} {hasOutput ? (
0 || sessionId) && 'pt-1')} > {isOutputExpanded ? (
) : null}
) : null}
); }; interface DiffPreviewProps { diff: string; pierreTheme: { light: string; dark: string }; pierreThemeType: 'light' | 'dark'; diffViewMode: DiffViewMode; } const TOOL_DIFF_UNSAFE_CSS = ` [data-diff-header], [data-diff] { [data-separator] { height: 24px !important; } } `; const TOOL_DIFF_METRICS = { hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, hunkSeparatorHeight: 24, fileGap: 0, }; type DiffPatchEntry = { id: string; title: string; patch: string; }; const renderPathLikeGitChanges = (path: string, grow = true) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { return ( {path} ); } const dir = path.slice(0, lastSlash); const name = path.slice(lastSlash + 1); return ( {dir} / {name} ); }; const getDiffPatchEntries = ( metadata: Record | undefined, fallbackDiff: string, currentDirectory: string, ): DiffPatchEntry[] => { const files = Array.isArray(metadata?.files) ? metadata.files : []; const entries = files .map((file, index) => { if (!file || typeof file !== 'object') { return null; } const record = file as { relativePath?: unknown; filePath?: unknown; diff?: unknown }; const patch = typeof record.diff === 'string' ? record.diff.trim() : ''; if (!patch) { return null; } const rawPath = typeof record.relativePath === 'string' ? record.relativePath : typeof record.filePath === 'string' ? record.filePath : `File ${index + 1}`; const title = typeof rawPath === 'string' ? getRelativePath(rawPath, currentDirectory) : `File ${index + 1}`; return { id: `${title}-${index}`, title, patch, } satisfies DiffPatchEntry; }) .filter((entry): entry is DiffPatchEntry => entry !== null); if (entries.length > 0) { return entries; } return [ { id: 'diff-0', title: 'Diff', patch: fallbackDiff, }, ]; }; const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => { return (
); }); DiffPreview.displayName = 'DiffPreview'; interface WriteInputPreviewProps { content: string; filePath?: string; displayPath: string; pierreTheme: { light: string; dark: string }; pierreThemeType: 'light' | 'dark'; } const WriteInputPreview: React.FC = React.memo(({ content, filePath, displayPath, pierreTheme, pierreThemeType, }) => { const language = React.useMemo( () => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined), [content, filePath] ); const lineCount = Math.max(content.split('\n').length, 1); const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; return (
{renderPathLikeGitChanges(displayPath)} ({headerLineLabel})
); }); WriteInputPreview.displayName = 'WriteInputPreview'; // ── PERF-007: Read tool output with virtualised highlighting ───────── interface ReadToolVirtualizedProps { outputString: string; input?: Record; syntaxTheme: { [key: string]: React.CSSProperties }; toolName: string; currentDirectory: string; pierreTheme: { light: string; dark: string }; pierreThemeType: 'light' | 'dark'; renderScrollableBlock: ( content: React.ReactNode, options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string } ) => React.ReactNode; } const ReadToolVirtualized: React.FC = React.memo(({ outputString, input, syntaxTheme, toolName, currentDirectory, pierreTheme, pierreThemeType, renderScrollableBlock, }) => { const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]); const language = React.useMemo(() => { const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n'); return detectLanguageFromOutput(contentForLanguage, toolName, input as Record); }, [parsedReadOutput, toolName, input]); const rawFilePath = typeof input?.filePath === 'string' ? input.filePath : typeof input?.file_path === 'string' ? input.file_path : typeof input?.path === 'string' ? input.path : 'read-output'; const displayPath = getRelativePath(rawFilePath, currentDirectory); const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({ text: line.text, lineNumber: line.lineNumber, isInfo: line.isInfo, })), [parsedReadOutput]); if (parsedReadOutput.type === 'file') { const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n'); const lineCount = Math.max(parsedReadOutput.lines.length, 1); const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; return renderScrollableBlock(
{renderPathLikeGitChanges(displayPath)} ({headerLineLabel})
, { className: 'p-1' } ) as React.ReactElement; } return renderScrollableBlock( , { className: 'p-1' } ) as React.ReactElement; }); ReadToolVirtualized.displayName = 'ReadToolVirtualized'; interface ImagePreviewProps { content: string; filePath: string; displayPath: string; } const ImagePreview: React.FC = React.memo(({ content, filePath, displayPath }) => { const mimeType = getImageMimeType(filePath); const isSvg = filePath.toLowerCase().endsWith('.svg'); // For SVG, content might be raw XML, otherwise assume base64 const imageSrc = React.useMemo(() => { if (isSvg && !content.startsWith('data:')) { // Raw SVG content return `data:image/svg+xml;base64,${btoa(content)}`; } if (content.startsWith('data:')) { return content; } // Assume base64 encoded return `data:${mimeType};base64,${content}`; }, [content, mimeType, isSvg]); return (
{renderPathLikeGitChanges(displayPath)}
{displayPath}
); }); ImagePreview.displayName = 'ImagePreview'; interface ToolExpandedContentProps { part: ToolPartType; state: ToolStateUnion; syntaxTheme: { [key: string]: React.CSSProperties }; isMobile: boolean; currentDirectory: string; onShowPopup?: (content: ToolPopupContent) => void; hasPrevTool: boolean; hasNextTool: boolean; } const ToolExpandedContent: React.FC = React.memo(({ part, state, syntaxTheme, isMobile, currentDirectory, onShowPopup, hasPrevTool, hasNextTool, }) => { const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); 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 diffEntries = React.useMemo( () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []), [currentDirectory, diffContent, metadata] ); 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 isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false; const writeDisplayPath = shouldShowWriteInputPreview ? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : '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 = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0; React.useEffect(() => { setDiffViewMode('unified'); }, [part.id]); const renderScrollableBlock = ( content: React.ReactNode, options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string } ) => ( {content} ); const renderResultContent = () => { // Question tool: show parsed Q&A summary if (part.tool === 'question') { if (state.status === 'completed' && hasStringOutput) { const parsedQA = parseQuestionOutput(outputString); if (parsedQA && parsedQA.length > 0) { return renderScrollableBlock(
{parsedQA.map((qa, index) => (
{qa.question}
{qa.answer}
))}
, { maxHeightClass: 'max-h-[40vh]' } ); } } if (state.status === 'error' && 'error' in state) { return (
Error:
{state.error}
); } return
Awaiting response...
; } 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 === 'skill' && hasStringOutput) { return renderScrollableBlock(
); } if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffEntries.length > 0) { return renderScrollableBlock(
{diffEntries.map((entry) => (
{diffEntries.length > 1 ? (
{renderPathLikeGitChanges(entry.title)}
) : null}
))}
, { className: 'p-1' } ); } if (hasStringOutput && outputString.trim()) { if (part.tool === 'read') { return ; } 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' || part.tool === 'question') ? ( renderResultContent() ) : ( <> {shouldShowWriteInputPreview && isWriteImageFile ? (
{renderScrollableBlock( )}
) : shouldShowWriteInputPreview ? (
{renderScrollableBlock( )}
) : hasInputText ? (
{renderScrollableBlock(
{inputTextContent}
, { maxHeightClass: 'max-h-60', className: 'tool-input-surface' } )}
) : null} {part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
Result:
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? ( ) : null}
{renderResultContent()}
)} {state.status === 'error' && 'error' in state && (
Error:
{state.error}
)} )}
); }); ToolExpandedContent.displayName = 'ToolExpandedContent'; const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, onShowPopup, hasPrevTool = false, hasNextTool = false, }) => { const state = part.state; const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const isTaskTool = part.tool.toLowerCase() === 'task'; const status = state.status as string | undefined; const isFinalized = status === 'completed' || status === 'error'; const isActive = status === 'running' || status === 'pending' || status === 'started'; const isError = state.status === 'error'; const shouldNotifyStructuralChange = isFinalized || isTaskTool; React.useEffect(() => { if (!shouldNotifyStructuralChange) { return; } if (typeof isExpanded === 'boolean') { onContentChange?.('structural'); } }, [isExpanded, onContentChange, shouldNotifyStructuralChange]); const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const time = stateWithData.time; const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>({}); React.useEffect(() => { setPinnedTime({}); }, [part.id]); React.useEffect(() => { setPinnedTime((prev) => { const next = { ...prev }; let changed = false; if (typeof time?.start === 'number' && (typeof prev.start !== 'number' || time.start < prev.start)) { next.start = time.start; changed = true; } if (typeof time?.end === 'number' && prev.end !== time.end) { next.end = time.end; changed = true; } return changed ? next : prev; }); }, [time?.end, time?.start]); const effectiveTimeStart = pinnedTime.start ?? time?.start; const effectiveTimeEnd = pinnedTime.end ?? time?.end; const endedTimestampText = React.useMemo(() => { if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) { return null; } const formatted = formatTimestampForDisplay(effectiveTimeEnd); return formatted.length > 0 ? formatted : null; }, [effectiveTimeEnd]); const taskOutputString = React.useMemo(() => { return typeof stateWithData.output === 'string' ? stateWithData.output : undefined; }, [stateWithData.output]); const parsedTaskMetadata = React.useMemo(() => { return parseTaskMetadataBlock(taskOutputString); }, [taskOutputString]); const taskSessionId = React.useMemo(() => { if (!isTaskTool) { return undefined; } const candidate = metadata as { sessionId?: string } | undefined; if (typeof candidate?.sessionId === 'string' && candidate.sessionId.trim().length > 0) { return candidate.sessionId; } if (parsedTaskMetadata.sessionId) { return parsedTaskMetadata.sessionId; } return readTaskSessionIdFromOutput(taskOutputString); }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, taskOutputString]); const childSessionMessages = useSessionStore( React.useCallback((store) => { if (!taskSessionId) { return EMPTY_SESSION_MESSAGES; } return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES; }, [taskSessionId]) ); const metadataTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool) { return []; } const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined); const normalized = normalizeTaskSummaryEntries( candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls ); if (normalized.length > 0) { return normalized; } return parsedTaskMetadata.summaryEntries; }, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]); const childSessionTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool || !taskSessionId) { return []; } if (!Array.isArray(childSessionMessages) || childSessionMessages.length === 0) { return []; } return buildTaskSummaryEntriesFromSession(childSessionMessages); }, [childSessionMessages, isTaskTool, taskSessionId]); const taskSummaryEntries = React.useMemo(() => { if (childSessionTaskSummaryEntries.length > 0) { return childSessionTaskSummaryEntries; } return metadataTaskSummaryEntries; }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]); const fetchedTaskSessionsRef = React.useRef>(new Set()); React.useEffect(() => { if (!isTaskTool || !taskSessionId) { return; } if (childSessionTaskSummaryEntries.length > 0) { return; } if (fetchedTaskSessionsRef.current.has(taskSessionId)) { return; } fetchedTaskSessionsRef.current.add(taskSessionId); let cancelled = false; void opencodeClient .getSessionMessages(taskSessionId, 500) .then((messages) => { if (cancelled || !Array.isArray(messages)) { return; } if (messages.length === 0) { fetchedTaskSessionsRef.current.delete(taskSessionId); return; } useSessionStore.getState().syncMessages(taskSessionId, messages); }) .catch(() => { fetchedTaskSessionsRef.current.delete(taskSessionId); }); return () => { cancelled = true; }; }, [childSessionTaskSummaryEntries.length, isTaskTool, taskSessionId]); 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' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null; const descriptionPath = getToolDescriptionPath(part, state, currentDirectory); const description = getToolDescription(part, state, currentDirectory); const displayName = getToolMetadata(part.tool).displayName; // Get justification text (tool title/description) when setting is enabled const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity); const justificationText = React.useMemo(() => { if (!showTextJustificationActivity) return null; if (part.tool === 'apply_patch') return null; if (part.tool.toLowerCase() === 'structuredoutput' || part.tool.toLowerCase() === 'structured_output') return null; // Get title or description from state - this is the "yapping" text like "Shows system information" const title = (stateWithData as { title?: string }).title; if (typeof title === 'string' && title.trim().length > 0) { return title; } const inputDesc = input?.description; if (typeof inputDesc === 'string' && inputDesc.trim().length > 0) { return inputDesc; } return null; }, [showTextJustificationActivity, part.tool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); const handleMainClick = (e: { stopPropagation: () => void }) => { 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 (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; filePath = firstFile?.relativePath || firstFile?.filePath; } 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); } }; const handleMainKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); handleMainClick(event); }; if (!isFinalized && !isActive && !isTaskTool) { return null; } return (
{}
{}
{ event.stopPropagation(); onToggle(part.id); }} > {}
{getToolIcon(part.tool)}
{}
{isExpanded ? : }
{displayName}
{justificationText && ( {justificationText} )} {!justificationText && description && ( descriptionPath && description === descriptionPath ? ( renderPathLikeGitChanges(descriptionPath, false) ) : ( {description} ) )} {diffStats && ( +{diffStats.added} {' '} -{diffStats.removed} )}
{typeof effectiveTimeStart === 'number' ? ( {!isMobile && endedTimestampText ? ( {endedTimestampText} ) : null} ) : null} {typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText ? ( {endedTimestampText} ) : null}
{} {isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized || taskSessionId) ? ( ) : null} {!isTaskTool && isExpanded ? ( ) : null}
); }; export default ToolPart;