mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-23 17:17:41 +00:00
220 lines
6.6 KiB
TypeScript
220 lines
6.6 KiB
TypeScript
import React, { memo, useMemo, useCallback } from 'react';
|
|
import { getToolConfig } from './configs/toolConfigs';
|
|
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
|
|
import type { Project } from '../../../types/app';
|
|
|
|
type DiffLine = {
|
|
type: string;
|
|
content: string;
|
|
lineNum: number;
|
|
};
|
|
|
|
interface ToolRendererProps {
|
|
toolName: string;
|
|
toolInput: any;
|
|
toolResult?: any;
|
|
toolId?: string;
|
|
mode: 'input' | 'result';
|
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
|
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
|
selectedProject?: Project | null;
|
|
autoExpandTools?: boolean;
|
|
showRawParameters?: boolean;
|
|
rawToolInput?: string;
|
|
}
|
|
|
|
function getToolCategory(toolName: string): string {
|
|
if (['Edit', 'Write', 'ApplyPatch'].includes(toolName)) return 'edit';
|
|
if (['Grep', 'Glob'].includes(toolName)) return 'search';
|
|
if (toolName === 'Bash') return 'bash';
|
|
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
|
|
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
|
|
if (toolName === 'Task') return 'agent'; // Subagent task
|
|
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
|
|
if (toolName === 'AskUserQuestion') return 'question';
|
|
return 'default';
|
|
}
|
|
|
|
/**
|
|
* Main tool renderer router
|
|
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config
|
|
*/
|
|
export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|
toolName,
|
|
toolInput,
|
|
toolResult,
|
|
toolId,
|
|
mode,
|
|
onFileOpen,
|
|
createDiff,
|
|
selectedProject,
|
|
autoExpandTools = false,
|
|
showRawParameters = false,
|
|
rawToolInput
|
|
}) => {
|
|
const config = getToolConfig(toolName);
|
|
const displayConfig: any = mode === 'input' ? config.input : config.result;
|
|
|
|
const parsedData = useMemo(() => {
|
|
try {
|
|
const rawData = mode === 'input' ? toolInput : toolResult;
|
|
return typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
|
} catch {
|
|
return mode === 'input' ? toolInput : toolResult;
|
|
}
|
|
}, [mode, toolInput, toolResult]);
|
|
|
|
const handleAction = useCallback(() => {
|
|
if (displayConfig?.action === 'open-file' && onFileOpen) {
|
|
const value = displayConfig.getValue?.(parsedData) || '';
|
|
onFileOpen(value);
|
|
}
|
|
}, [displayConfig, parsedData, onFileOpen]);
|
|
|
|
// Keep hooks above this guard so hook call order stays stable across renders.
|
|
if (!displayConfig) return null;
|
|
|
|
if (displayConfig.type === 'one-line') {
|
|
const value = displayConfig.getValue?.(parsedData) || '';
|
|
const secondary = displayConfig.getSecondary?.(parsedData);
|
|
|
|
return (
|
|
<OneLineDisplay
|
|
toolName={toolName}
|
|
toolResult={toolResult}
|
|
toolId={toolId}
|
|
icon={displayConfig.icon}
|
|
label={displayConfig.label}
|
|
value={value}
|
|
secondary={secondary}
|
|
action={displayConfig.action}
|
|
onAction={handleAction}
|
|
style={displayConfig.style}
|
|
wrapText={displayConfig.wrapText}
|
|
colorScheme={displayConfig.colorScheme}
|
|
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (displayConfig.type === 'collapsible') {
|
|
const title = typeof displayConfig.title === 'function'
|
|
? displayConfig.title(parsedData)
|
|
: displayConfig.title || 'Details';
|
|
|
|
const defaultOpen = displayConfig.defaultOpen !== undefined
|
|
? displayConfig.defaultOpen
|
|
: autoExpandTools;
|
|
|
|
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
|
selectedProject,
|
|
createDiff,
|
|
onFileOpen
|
|
}) || {};
|
|
|
|
// Build the content component based on contentType
|
|
let contentComponent: React.ReactNode = null;
|
|
|
|
switch (displayConfig.contentType) {
|
|
case 'diff':
|
|
if (createDiff) {
|
|
contentComponent = (
|
|
<DiffViewer
|
|
{...contentProps}
|
|
createDiff={createDiff}
|
|
onFileClick={() => onFileOpen?.(contentProps.filePath)}
|
|
/>
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'markdown':
|
|
contentComponent = <MarkdownContent content={contentProps.content || ''} />;
|
|
break;
|
|
|
|
case 'file-list':
|
|
contentComponent = (
|
|
<FileListContent
|
|
files={contentProps.files || []}
|
|
onFileClick={onFileOpen}
|
|
title={contentProps.title}
|
|
/>
|
|
);
|
|
break;
|
|
|
|
case 'todo-list':
|
|
if (contentProps.todos?.length > 0) {
|
|
contentComponent = (
|
|
<TodoListContent
|
|
todos={contentProps.todos}
|
|
isResult={contentProps.isResult}
|
|
/>
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'task':
|
|
contentComponent = <TaskListContent content={contentProps.content || ''} />;
|
|
break;
|
|
|
|
case 'question-answer':
|
|
contentComponent = (
|
|
<QuestionAnswerContent
|
|
questions={contentProps.questions || []}
|
|
answers={contentProps.answers || {}}
|
|
/>
|
|
);
|
|
break;
|
|
|
|
case 'text':
|
|
contentComponent = (
|
|
<TextContent
|
|
content={contentProps.content || ''}
|
|
format={contentProps.format || 'plain'}
|
|
/>
|
|
);
|
|
break;
|
|
|
|
case 'success-message': {
|
|
const msg = displayConfig.getMessage?.(parsedData) || 'Success';
|
|
contentComponent = (
|
|
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{msg}
|
|
</div>
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// For edit tools, make the title (filename) clickable to open the file
|
|
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
|
|
? () => onFileOpen(contentProps.filePath, {
|
|
old_string: contentProps.oldContent,
|
|
new_string: contentProps.newContent
|
|
})
|
|
: undefined;
|
|
|
|
return (
|
|
<CollapsibleDisplay
|
|
toolName={toolName}
|
|
toolId={toolId}
|
|
title={title}
|
|
defaultOpen={defaultOpen}
|
|
onTitleClick={handleTitleClick}
|
|
showRawParameters={mode === 'input' && showRawParameters}
|
|
rawContent={rawToolInput}
|
|
toolCategory={getToolCategory(toolName)}
|
|
>
|
|
{contentComponent}
|
|
</CollapsibleDisplay>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
ToolRenderer.displayName = 'ToolRenderer';
|