mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 04:37:32 +00:00
refactor: tool components
This commit is contained in:
@@ -11,6 +11,7 @@ import { Markdown } from '../markdown/Markdown';
|
||||
import { formatUsageLimitText } from '../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../utils/chatPermissions';
|
||||
import type { Project } from '../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../tools';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
@@ -45,6 +46,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
@@ -232,423 +234,28 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{message.toolInput && message.toolName === 'Edit' && (() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path && input.old_string && input.new_string) {
|
||||
return (
|
||||
<details className="relative mt-3 group/details" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-4 h-4 transition-transform duration-200 group-open/details:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>View edit diff for</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the current file (after the edit)
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
console.error('Failed to fetch file:', data.error);
|
||||
onFileOpen(input.file_path);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = data.content || '';
|
||||
|
||||
// Reverse apply the edit: replace new_string back to old_string to get the file BEFORE the edit
|
||||
const oldContent = currentContent.replace(input.new_string, input.old_string);
|
||||
|
||||
// Pass the full file before and after the edit
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: oldContent,
|
||||
new_string: currentContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
onFileOpen(input.file_path);
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm"
|
||||
>
|
||||
{input.file_path.split('/').pop()}
|
||||
</button>
|
||||
</summary>
|
||||
<div className="mt-3 pl-6">
|
||||
<div className="bg-white dark:bg-gray-900/50 border border-gray-200/60 dark:border-gray-700/60 rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/80 dark:to-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/60 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the current file (after the edit)
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
console.error('Failed to fetch file:', data.error);
|
||||
onFileOpen(input.file_path);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = data.content || '';
|
||||
// Reverse apply the edit: replace new_string back to old_string
|
||||
const oldContent = currentContent.replace(input.new_string, input.old_string);
|
||||
|
||||
// Pass the full file before and after the edit
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: oldContent,
|
||||
new_string: currentContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
onFileOpen(input.file_path);
|
||||
}
|
||||
}}
|
||||
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
|
||||
>
|
||||
{input.file_path}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium px-2 py-0.5 bg-gray-100 dark:bg-gray-700/50 rounded">
|
||||
Diff
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono">
|
||||
{createDiff(input.old_string, input.new_string).map((diffLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className={`w-8 text-center border-r ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
{diffLine.type === 'removed' ? '-' : '+'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
}`}>
|
||||
{diffLine.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showRawParameters && (
|
||||
<details className="relative mt-3 pl-6 group/raw" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to raw display if parsing fails
|
||||
}
|
||||
{/* All tool input rendering now handled by ToolRenderer */}
|
||||
{message.toolInput && (() => {
|
||||
// Use new ToolRenderer for all tools (config-driven)
|
||||
return (
|
||||
<details className="relative mt-3 group/params" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-4 h-4 transition-transform duration-200 group-open/params:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
View input parameters
|
||||
</summary>
|
||||
<pre className="mt-3 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
);
|
||||
})()}
|
||||
{message.toolInput && message.toolName !== 'Edit' && (() => {
|
||||
// Debug log to see what we're dealing with
|
||||
|
||||
// Special handling for Write tool
|
||||
if (message.toolName === 'Write') {
|
||||
try {
|
||||
let input;
|
||||
// Handle both JSON string and already parsed object
|
||||
if (typeof message.toolInput === 'string') {
|
||||
input = JSON.parse(message.toolInput);
|
||||
} else {
|
||||
input = message.toolInput;
|
||||
}
|
||||
|
||||
|
||||
if (input.file_path && input.content !== undefined) {
|
||||
return (
|
||||
<details className="relative mt-3 group/details" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-4 h-4 transition-transform duration-200 group-open/details:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg leading-none">📄</span>
|
||||
<span>Creating new file:</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the written file from disk
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
|
||||
|
||||
// New file: old_string is empty, new_string is the full file
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: newContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
// Fallback to tool input content
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content || ''
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm"
|
||||
>
|
||||
{input.file_path.split('/').pop()}
|
||||
</button>
|
||||
</summary>
|
||||
<div className="mt-3 pl-6">
|
||||
<div className="bg-white dark:bg-gray-900/50 border border-gray-200/60 dark:border-gray-700/60 rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/80 dark:to-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/60 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the written file from disk
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
|
||||
|
||||
// New file: old_string is empty, new_string is the full file
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: newContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
// Fallback to tool input content
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content || ''
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
|
||||
>
|
||||
{input.file_path}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
|
||||
New File
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono">
|
||||
{createDiff('', input.content).map((diffLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className={`w-8 text-center border-r ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
{diffLine.type === 'removed' ? '-' : '+'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
}`}>
|
||||
{diffLine.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showRawParameters && (
|
||||
<details className="relative mt-3 pl-6 group/raw" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for TodoWrite tool
|
||||
if (message.toolName === 'TodoWrite') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.todos && Array.isArray(input.todos)) {
|
||||
return (
|
||||
<details className="relative mt-3 group/todo" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-4 h-4 transition-transform duration-200 group-open/todo:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg leading-none">✓</span>
|
||||
<span>Updating Todo List</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<TodoList todos={input.todos} />
|
||||
{showRawParameters && (
|
||||
<details className="relative mt-3 group/raw" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg overflow-x-auto text-gray-700 dark:text-gray-300 font-mono">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for Bash tool
|
||||
if (message.toolName === 'Bash') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-md px-3 py-2 font-mono text-sm">
|
||||
<span className="text-green-400">$</span>
|
||||
<span className="text-gray-100 ml-2">{input.command}</span>
|
||||
</div>
|
||||
{input.description && (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 italic ml-1">
|
||||
{input.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for Read tool
|
||||
if (message.toolName === 'Read') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
const filename = input.file_path.split('/').pop();
|
||||
|
||||
return (
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for exit_plan_mode tool
|
||||
if (message.toolName === 'exit_plan_mode') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.plan) {
|
||||
// Replace escaped newlines with actual newlines
|
||||
const planContent = input.plan.replace(/\\n/g, '\n');
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
📋 View implementation plan
|
||||
</summary>
|
||||
<Markdown className="mt-3 prose prose-sm max-w-none dark:prose-invert">
|
||||
{planContent}
|
||||
</Markdown>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Regular tool input display for other tools
|
||||
return (
|
||||
<details className="relative mt-3 group/params" open={autoExpandTools}>
|
||||
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg className="w-4 h-4 transition-transform duration-200 group-open/params:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
View input parameters
|
||||
</summary>
|
||||
<pre className="mt-3 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
<ToolRenderer
|
||||
toolName={message.toolName}
|
||||
toolInput={message.toolInput}
|
||||
mode="input"
|
||||
onFileOpen={onFileOpen}
|
||||
createDiff={createDiff}
|
||||
selectedProject={selectedProject}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
rawToolInput={message.toolInput}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Tool Result Section */}
|
||||
{message.toolResult && (() => {
|
||||
// Hide tool results for Edit/Write/Bash unless there's an error
|
||||
const shouldHideResult = !message.toolResult.isError &&
|
||||
(message.toolName === 'Edit' || message.toolName === 'Write' || message.toolName === 'ApplyPatch' || message.toolName === 'Bash');
|
||||
|
||||
if (shouldHideResult) {
|
||||
// Use config to determine if result should be hidden
|
||||
if (shouldHideToolResult(message.toolName, message.toolResult)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -737,9 +344,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
// Special handling for exit_plan_mode tool results
|
||||
if (message.toolName === 'exit_plan_mode') {
|
||||
try {
|
||||
// The content should be JSON with a "plan" field
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed.plan) {
|
||||
// Content might already be an object or a JSON string
|
||||
let parsed;
|
||||
if (typeof message.toolResult.content === 'object' && message.toolResult.content !== null) {
|
||||
parsed = message.toolResult.content;
|
||||
} else {
|
||||
parsed = JSON.parse(content);
|
||||
}
|
||||
|
||||
if (parsed && parsed.plan) {
|
||||
// Replace escaped newlines with actual newlines
|
||||
const planContent = parsed.plan.replace(/\\n/g, '\n');
|
||||
return (
|
||||
@@ -754,6 +367,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse exit_plan_mode result:', e);
|
||||
// Fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
435
src/components/chat/tools/README.md
Normal file
435
src/components/chat/tools/README.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Tool Rendering System
|
||||
|
||||
## Overview
|
||||
|
||||
This folder contains a **config-driven architecture** for rendering tool executions in the chat interface. Instead of scattered conditional logic, all tool rendering is centralized through configurations and reusable display components.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
tools/
|
||||
├── components/ # Reusable display components
|
||||
│ ├── OneLineDisplay.tsx # Simple one-line tool displays
|
||||
│ ├── CollapsibleDisplay.tsx # Expandable tool displays
|
||||
│ ├── ContentRenderers/ # Content-specific renderers
|
||||
│ │ ├── DiffViewer.tsx # File diff viewer
|
||||
│ │ ├── MarkdownContent.tsx # Markdown renderer
|
||||
│ │ ├── FileListContent.tsx # File list for search results
|
||||
│ │ ├── TodoListContent.tsx # Todo list renderer
|
||||
│ │ └── TextContent.tsx # Plain text/JSON/code
|
||||
│ ├── FilePathButton.tsx # Clickable file paths
|
||||
│ └── CollapsibleSection.tsx # Collapsible wrapper
|
||||
├── configs/ # Tool configurations
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ └── toolConfigs.ts # All tool configs (10 tools)
|
||||
├── ToolRenderer.tsx # Main router component
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Display Components
|
||||
|
||||
All tools use one of two base display patterns:
|
||||
|
||||
#### **OneLineDisplay** - For simple tools
|
||||
Used by: Bash, Read, Grep, Glob, TodoRead
|
||||
|
||||
```tsx
|
||||
<OneLineDisplay
|
||||
icon="$" // Optional icon
|
||||
label="Read" // Optional label
|
||||
value="command text" // Main value to display
|
||||
secondary="(desc)" // Optional secondary text
|
||||
action="copy" // Action type: copy | open-file | jump-to-results | none
|
||||
onAction={() => ...} // Action callback
|
||||
colorScheme={{ // Optional color customization
|
||||
primary: "text-...",
|
||||
secondary: "text-..."
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### **CollapsibleDisplay** - For complex tools
|
||||
Used by: Edit, Write, Plan, TodoWrite, Grep/Glob results
|
||||
|
||||
```tsx
|
||||
<CollapsibleDisplay
|
||||
title="View edit diff" // Section title
|
||||
defaultOpen={false} // Expand by default?
|
||||
action={<FilePathButton />} // Optional action button
|
||||
contentType="diff" // Type of content to render
|
||||
contentProps={{...}} // Props for content renderer
|
||||
showRawParameters={true} // Show raw JSON?
|
||||
rawContent="..." // Raw JSON content
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. Content Renderers
|
||||
|
||||
Different content types are handled by specialized renderers:
|
||||
|
||||
- **diff** → `DiffViewer` - Shows before/after file changes
|
||||
- **markdown** → `MarkdownContent` - Renders markdown with styling
|
||||
- **file-list** → `FileListContent` - Clickable file list
|
||||
- **todo-list** → `TodoListContent` - Todo items with status
|
||||
- **text** → `TextContent` - Plain text, JSON, or code
|
||||
|
||||
### 3. Configuration-Driven
|
||||
|
||||
Every tool is defined by a config object. No code changes needed to add/modify tools!
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Tool
|
||||
|
||||
### Example: Adding a "Format" tool
|
||||
|
||||
**Step 1:** Add config to `configs/toolConfigs.ts`
|
||||
|
||||
```typescript
|
||||
Format: {
|
||||
input: {
|
||||
type: 'one-line', // or 'collapsible'
|
||||
label: 'Format',
|
||||
getValue: (input) => input.file_path,
|
||||
action: 'open-file',
|
||||
colorScheme: {
|
||||
primary: 'text-purple-600 dark:text-purple-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true // Hide successful results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** That's it! No other files to touch.
|
||||
|
||||
The ToolRenderer automatically:
|
||||
- Parses the tool input
|
||||
- Selects the right display component
|
||||
- Passes the correct props
|
||||
- Handles callbacks (file opening, copy, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Input Configuration
|
||||
|
||||
```typescript
|
||||
input: {
|
||||
// Display type (required)
|
||||
type: 'one-line' | 'collapsible' | 'hidden'
|
||||
|
||||
// One-line specific
|
||||
icon?: string // Icon to display (e.g., "$", "✓")
|
||||
label?: string // Text label (e.g., "Read", "Grep")
|
||||
getValue?: (input) => string // Extract main value from input
|
||||
getSecondary?: (input) => string // Extract secondary text (description)
|
||||
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none'
|
||||
colorScheme?: {
|
||||
primary?: string // Tailwind classes for main text
|
||||
secondary?: string // Tailwind classes for secondary text
|
||||
}
|
||||
|
||||
// Collapsible specific
|
||||
title?: string | ((input) => string) // Section title
|
||||
defaultOpen?: boolean // Auto-expand?
|
||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text'
|
||||
getContentProps?: (input, helpers) => any // Extract props for content renderer
|
||||
actionButton?: 'file-button' | 'none' // Show file path button?
|
||||
}
|
||||
```
|
||||
|
||||
### Result Configuration
|
||||
|
||||
```typescript
|
||||
result?: {
|
||||
hidden?: boolean // Never show results
|
||||
hideOnSuccess?: boolean // Only show errors
|
||||
type?: 'one-line' | 'collapsible' | 'special'
|
||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message'
|
||||
getMessage?: (result) => string // For success messages
|
||||
getContentProps?: (result) => any // Extract content props
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Simple One-Line Tool (Bash)
|
||||
|
||||
```typescript
|
||||
Bash: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
icon: '$',
|
||||
getValue: (input) => input.command,
|
||||
getSecondary: (input) => input.description,
|
||||
action: 'copy'
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Renders:**
|
||||
```
|
||||
$ npm install (Install dependencies) [Copy Button]
|
||||
```
|
||||
|
||||
### Collapsible Diff Tool (Edit)
|
||||
|
||||
```typescript
|
||||
Edit: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'View edit diff for',
|
||||
contentType: 'diff',
|
||||
actionButton: 'file-button',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: input.old_string,
|
||||
newContent: input.new_string,
|
||||
filePath: input.file_path
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Renders:**
|
||||
```
|
||||
▶ View edit diff for [file.ts]
|
||||
┌─────────────────────────┐
|
||||
│ - old line │
|
||||
│ + new line │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### Search Results (Grep)
|
||||
|
||||
```typescript
|
||||
Grep: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Grep',
|
||||
getValue: (input) => input.pattern,
|
||||
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
|
||||
action: 'jump-to-results'
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'file-list',
|
||||
getContentProps: (result) => ({
|
||||
files: result.toolUseResult?.filenames || [],
|
||||
title: `Found ${count} files`
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Renders:**
|
||||
```
|
||||
Input: Grep "TODO" in src/ [Search results ↓]
|
||||
|
||||
Result: Found 5 files
|
||||
📄 app.ts (src/)
|
||||
📄 utils.ts (src/utils/)
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Dynamic Content Props with Helpers
|
||||
|
||||
For tools that need API calls or complex logic:
|
||||
|
||||
```typescript
|
||||
getContentProps: (input, helpers) => {
|
||||
const { selectedProject, createDiff, onFileOpen } = helpers;
|
||||
|
||||
// Use helpers for complex operations
|
||||
return {
|
||||
filePath: input.file_path,
|
||||
onFileClick: () => onFileOpen(input.file_path)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### File Opening Logic
|
||||
|
||||
The ToolRenderer handles file opening automatically for Edit/Write tools:
|
||||
|
||||
1. Fetches current file via API
|
||||
2. Reverse-applies edits (for Edit)
|
||||
3. Opens file in diff view
|
||||
4. Falls back gracefully on errors
|
||||
|
||||
No config needed - handled by `actionButton: 'file-button'`.
|
||||
|
||||
### Custom Color Schemes
|
||||
|
||||
Override default colors per tool:
|
||||
|
||||
```typescript
|
||||
colorScheme: {
|
||||
primary: 'text-purple-600 dark:text-purple-400',
|
||||
secondary: 'text-purple-400 dark:text-purple-500'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Props Reference
|
||||
|
||||
### ToolRenderer (Main Entry Point)
|
||||
|
||||
```typescript
|
||||
<ToolRenderer
|
||||
toolName="Bash" // Tool identifier
|
||||
toolInput={...} // Tool input (string or object)
|
||||
toolResult={...} // Tool result (for mode='result')
|
||||
mode="input" | "result" // Rendering mode
|
||||
// Callbacks
|
||||
onFileOpen={(path, diff) => ...}
|
||||
createDiff={(old, new) => [...]}
|
||||
// Context
|
||||
selectedProject={...}
|
||||
// Display options
|
||||
autoExpandTools={false}
|
||||
showRawParameters={false}
|
||||
rawToolInput="..."
|
||||
/>
|
||||
```
|
||||
|
||||
### OneLineDisplay
|
||||
|
||||
```typescript
|
||||
interface OneLineDisplayProps {
|
||||
icon?: string;
|
||||
label?: string;
|
||||
value: string; // Required
|
||||
secondary?: string;
|
||||
action?: ActionType;
|
||||
onAction?: () => void;
|
||||
colorScheme?: { primary, secondary };
|
||||
resultId?: string; // For jump-to-results
|
||||
}
|
||||
```
|
||||
|
||||
### CollapsibleDisplay
|
||||
|
||||
```typescript
|
||||
interface CollapsibleDisplayProps {
|
||||
title: string; // Required
|
||||
defaultOpen?: boolean;
|
||||
action?: React.ReactNode;
|
||||
contentType: ContentType; // Required
|
||||
contentProps: any; // Required
|
||||
showRawParameters?: boolean;
|
||||
rawContent?: string;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Your Tool
|
||||
|
||||
### 1. Add Config
|
||||
Add your tool to `toolConfigs.ts`
|
||||
|
||||
### 2. Test Input Rendering
|
||||
Trigger the tool and verify:
|
||||
- ✅ Correct display component used (one-line vs collapsible)
|
||||
- ✅ Values extracted correctly from input
|
||||
- ✅ Actions work (copy, file open, jump)
|
||||
- ✅ Colors and styling correct
|
||||
|
||||
### 3. Test Result Rendering
|
||||
Check tool results:
|
||||
- ✅ Results hidden when appropriate
|
||||
- ✅ Error results always shown
|
||||
- ✅ Content rendered correctly
|
||||
- ✅ Interactive elements work
|
||||
|
||||
### 4. Test Edge Cases
|
||||
- Empty inputs
|
||||
- Missing fields
|
||||
- Parse errors
|
||||
- API failures
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Performance Notes
|
||||
|
||||
### Config Loading
|
||||
- Configs are loaded once at module import
|
||||
- No runtime overhead for config lookups
|
||||
- Tree-shaking removes unused configs in production
|
||||
|
||||
### Component Rendering
|
||||
- Display components are memoized where appropriate
|
||||
- Content renderers only render when props change
|
||||
- Collapsible sections lazy-load content
|
||||
|
||||
### API Calls
|
||||
- File opening uses async/await with error handling
|
||||
- Failures gracefully fall back to simple file open
|
||||
- API module dynamically imported to reduce bundle size
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- [ ] Result rendering migration (currently partial)
|
||||
- [ ] Icon component system (replace emoji with SVG)
|
||||
- [ ] Interactive prompt renderer
|
||||
- [ ] Streaming tool output support
|
||||
- [ ] Tool output caching
|
||||
- [ ] Custom theme support per tool category
|
||||
|
||||
### Extensibility
|
||||
The architecture supports:
|
||||
- Custom display components
|
||||
- New content types
|
||||
- Plugin-style tool additions
|
||||
- Theme overrides
|
||||
- Internationalization (i18n)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### All Configured Tools
|
||||
|
||||
| Tool | Type | Display | Action | Result |
|
||||
|------|------|---------|--------|--------|
|
||||
| Bash | one-line | $ command | copy | hide success |
|
||||
| Read | one-line | Read file.ts | open-file | hidden |
|
||||
| Edit | collapsible | diff viewer | file-button | hide success |
|
||||
| Write | collapsible | diff viewer | file-button | hide success |
|
||||
| ApplyPatch | collapsible | diff viewer | file-button | hide success |
|
||||
| Grep | one-line | pattern | jump | file-list |
|
||||
| Glob | one-line | pattern | jump | file-list |
|
||||
| TodoWrite | collapsible | todo-list | none | success msg |
|
||||
| TodoRead | one-line | Read todo | none | todo-list |
|
||||
| exit_plan_mode | collapsible | markdown | none | markdown |
|
||||
| Default | collapsible | text/code | none | text |
|
||||
|
||||
259
src/components/chat/tools/ToolRenderer.tsx
Normal file
259
src/components/chat/tools/ToolRenderer.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, FilePathButton, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TextContent } from './components';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface ToolRendererProps {
|
||||
toolName: string;
|
||||
toolInput: any;
|
||||
toolResult?: any;
|
||||
mode: 'input' | 'result';
|
||||
// Callbacks and helpers
|
||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
||||
selectedProject?: Project | null;
|
||||
// Display options
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
rawToolInput?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main tool renderer router
|
||||
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config
|
||||
*/
|
||||
export const ToolRenderer: React.FC<ToolRendererProps> = ({
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResult,
|
||||
mode,
|
||||
onFileOpen,
|
||||
createDiff,
|
||||
selectedProject,
|
||||
autoExpandTools = false,
|
||||
showRawParameters = false,
|
||||
rawToolInput
|
||||
}) => {
|
||||
const config = getToolConfig(toolName);
|
||||
const displayConfig = mode === 'input' ? config.input : config.result;
|
||||
|
||||
if (!displayConfig) return null;
|
||||
|
||||
// Parse tool input/result
|
||||
let parsedData: any;
|
||||
try {
|
||||
const rawData = mode === 'input' ? toolInput : toolResult;
|
||||
parsedData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
||||
} catch (e) {
|
||||
parsedData = mode === 'input' ? toolInput : toolResult;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ONE-LINE DISPLAY
|
||||
// ============================================================================
|
||||
if (displayConfig.type === 'one-line') {
|
||||
const value = displayConfig.getValue?.(parsedData) || '';
|
||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||
|
||||
const handleAction = () => {
|
||||
if (displayConfig.action === 'open-file' && onFileOpen) {
|
||||
onFileOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OneLineDisplay
|
||||
icon={displayConfig.icon}
|
||||
label={displayConfig.label}
|
||||
value={value}
|
||||
secondary={secondary}
|
||||
action={displayConfig.action}
|
||||
onAction={handleAction}
|
||||
colorScheme={displayConfig.colorScheme}
|
||||
resultId={mode === 'input' ? `tool-result-${toolName}` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLAPSIBLE DISPLAY
|
||||
// ============================================================================
|
||||
if (displayConfig.type === 'collapsible') {
|
||||
const title = typeof displayConfig.title === 'function'
|
||||
? displayConfig.title(parsedData)
|
||||
: displayConfig.title || 'View details';
|
||||
|
||||
const defaultOpen = displayConfig.defaultOpen !== undefined
|
||||
? displayConfig.defaultOpen
|
||||
: autoExpandTools;
|
||||
|
||||
// Get content props from config
|
||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||
selectedProject,
|
||||
createDiff,
|
||||
onFileOpen
|
||||
}) || {};
|
||||
|
||||
// Render content based on contentType
|
||||
let contentComponent = null;
|
||||
|
||||
switch (displayConfig.contentType) {
|
||||
case 'diff':
|
||||
if (!createDiff) {
|
||||
console.error('createDiff function required for diff content type');
|
||||
break;
|
||||
}
|
||||
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 || contentProps.todos.length === 0) {
|
||||
contentComponent = null;
|
||||
break;
|
||||
}
|
||||
contentComponent = (
|
||||
<TodoListContent
|
||||
todos={contentProps.todos}
|
||||
isResult={contentProps.isResult}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
contentComponent = (
|
||||
<TextContent
|
||||
content={contentProps.content || ''}
|
||||
format={contentProps.format || 'plain'}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'success-message':
|
||||
const message = displayConfig.getMessage?.(parsedData) || 'Success';
|
||||
contentComponent = (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium">{message}</span>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
contentComponent = (
|
||||
<div className="text-gray-500">Unknown content type: {displayConfig.contentType}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Action button for file operations
|
||||
let actionButton = null;
|
||||
if (displayConfig.actionButton === 'file-button' && contentProps.filePath) {
|
||||
const handleFileClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!onFileOpen) return;
|
||||
|
||||
// For Edit/ApplyPatch tools, fetch current file and reverse-apply the edit
|
||||
if (toolName === 'Edit' || toolName === 'ApplyPatch') {
|
||||
try {
|
||||
const { api } = await import('../../../utils/api');
|
||||
const response = await api.readFile(selectedProject?.name, contentProps.filePath);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
console.error('Failed to fetch file:', data.error);
|
||||
onFileOpen(contentProps.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = data.content || '';
|
||||
const oldContent = currentContent.replace(contentProps.newContent, contentProps.oldContent);
|
||||
|
||||
onFileOpen(contentProps.filePath, {
|
||||
old_string: oldContent,
|
||||
new_string: currentContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
onFileOpen(contentProps.filePath);
|
||||
}
|
||||
}
|
||||
// For Write tool, fetch written file
|
||||
else if (toolName === 'Write') {
|
||||
try {
|
||||
const { api } = await import('../../../utils/api');
|
||||
const response = await api.readFile(selectedProject?.name, contentProps.filePath);
|
||||
const data = await response.json();
|
||||
|
||||
const newContent = (response.ok && !data.error) ? data.content || '' : contentProps.newContent || '';
|
||||
|
||||
onFileOpen(contentProps.filePath, {
|
||||
old_string: '',
|
||||
new_string: newContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
onFileOpen(contentProps.filePath, {
|
||||
old_string: '',
|
||||
new_string: contentProps.newContent || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
actionButton = (
|
||||
<FilePathButton
|
||||
filePath={contentProps.filePath}
|
||||
onClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleDisplay
|
||||
title={title}
|
||||
defaultOpen={defaultOpen}
|
||||
action={actionButton}
|
||||
contentType={displayConfig.contentType || 'text'}
|
||||
contentProps={{
|
||||
DiffViewer: contentComponent,
|
||||
MarkdownComponent: contentComponent,
|
||||
FileListComponent: contentComponent,
|
||||
TodoListComponent: contentComponent,
|
||||
TextComponent: contentComponent
|
||||
}}
|
||||
showRawParameters={mode === 'input' && showRawParameters}
|
||||
rawContent={rawToolInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SPECIAL / HIDDEN
|
||||
// ============================================================================
|
||||
return null;
|
||||
};
|
||||
92
src/components/chat/tools/components/CollapsibleDisplay.tsx
Normal file
92
src/components/chat/tools/components/CollapsibleDisplay.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
|
||||
type ContentType = 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text';
|
||||
|
||||
interface CollapsibleDisplayProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
action?: React.ReactNode;
|
||||
contentType: ContentType;
|
||||
contentProps: any;
|
||||
showRawParameters?: boolean;
|
||||
rawContent?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified collapsible display for complex tool inputs and results
|
||||
* Used by: Edit, Write, Plan, TodoWrite, Grep/Glob (results), etc.
|
||||
*
|
||||
* Content is rendered by specialized components based on contentType
|
||||
*/
|
||||
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
action,
|
||||
contentType,
|
||||
contentProps,
|
||||
showRawParameters = false,
|
||||
rawContent,
|
||||
className = ''
|
||||
}) => {
|
||||
// Import content renderers dynamically based on type
|
||||
const renderContent = () => {
|
||||
switch (contentType) {
|
||||
case 'diff':
|
||||
// DiffViewer already exists - will be imported by ToolRenderer
|
||||
return contentProps.DiffViewer;
|
||||
|
||||
case 'markdown':
|
||||
// Markdown component already exists - will be imported by ToolRenderer
|
||||
return contentProps.MarkdownComponent;
|
||||
|
||||
case 'file-list':
|
||||
// FileListContent will be created
|
||||
return contentProps.FileListComponent;
|
||||
|
||||
case 'todo-list':
|
||||
// TodoListContent will be created
|
||||
return contentProps.TodoListComponent;
|
||||
|
||||
case 'text':
|
||||
// TextContent will be created
|
||||
return contentProps.TextComponent;
|
||||
|
||||
default:
|
||||
return <div className="text-gray-500">Unknown content type: {contentType}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
title={title}
|
||||
open={defaultOpen}
|
||||
action={action}
|
||||
className={className}
|
||||
>
|
||||
{/* Main content */}
|
||||
{renderContent()}
|
||||
|
||||
{/* Optional raw parameters viewer */}
|
||||
{showRawParameters && rawContent && (
|
||||
<details className="relative mt-3 pl-6 group/raw" open={defaultOpen}>
|
||||
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg
|
||||
className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
|
||||
{rawContent}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
43
src/components/chat/tools/components/CollapsibleSection.tsx
Normal file
43
src/components/chat/tools/components/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
open?: boolean;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable collapsible section with consistent styling
|
||||
* Replaces repeated details/summary patterns throughout MessageComponent
|
||||
*/
|
||||
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
open = false,
|
||||
action,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<details className={`relative mt-3 group/details ${className}`} open={open}>
|
||||
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform duration-200 group-open/details:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span className="flex items-center gap-2 flex-1">
|
||||
{title}
|
||||
</span>
|
||||
{action}
|
||||
</summary>
|
||||
<div className="mt-3 pl-6">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FileListItem {
|
||||
path: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface FileListContentProps {
|
||||
files: string[] | FileListItem[];
|
||||
onFileClick?: (filePath: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of files with click handlers
|
||||
* Used by: Grep/Glob results
|
||||
*/
|
||||
export const FileListContent: React.FC<FileListContentProps> = ({
|
||||
files,
|
||||
onFileClick,
|
||||
title
|
||||
}) => {
|
||||
const fileCount = files.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium">
|
||||
{title || `Found ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||
{files.map((file, index) => {
|
||||
const filePath = typeof file === 'string' ? file : file.path;
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
const handleClick = typeof file === 'string'
|
||||
? () => onFileClick?.(file)
|
||||
: file.onClick;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={handleClick}
|
||||
className="group flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer transition-colors"
|
||||
>
|
||||
{/* File icon */}
|
||||
<svg className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
|
||||
{/* File path */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{fileName}
|
||||
</div>
|
||||
{dirPath && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{dirPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron on hover */}
|
||||
<svg className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Markdown } from '../../../markdown/Markdown';
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders markdown content with proper styling
|
||||
* Used by: exit_plan_mode, long text results, etc.
|
||||
*/
|
||||
export const MarkdownContent: React.FC<MarkdownContentProps> = ({
|
||||
content,
|
||||
className = 'mt-3 prose prose-sm max-w-none dark:prose-invert'
|
||||
}) => {
|
||||
return (
|
||||
<Markdown className={className}>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TextContentProps {
|
||||
content: string;
|
||||
format?: 'plain' | 'json' | 'code';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders plain text, JSON, or code content
|
||||
* Used by: Raw parameters, generic text results, JSON responses
|
||||
*/
|
||||
export const TextContent: React.FC<TextContentProps> = ({
|
||||
content,
|
||||
format = 'plain',
|
||||
className = ''
|
||||
}) => {
|
||||
if (format === 'json') {
|
||||
let formattedJson = content;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
formattedJson = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
// If parsing fails, use original content
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className={`mt-2 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-4 rounded-lg overflow-x-auto font-mono ${className}`}>
|
||||
{formattedJson}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === 'code') {
|
||||
return (
|
||||
<pre className={`mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}>
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Plain text
|
||||
return (
|
||||
<div className={`mt-2 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import TodoList from '../../../../TodoList';
|
||||
|
||||
interface TodoListContentProps {
|
||||
todos: Array<{
|
||||
content: string;
|
||||
status: string;
|
||||
activeForm?: string;
|
||||
}>;
|
||||
isResult?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a todo list
|
||||
* Used by: TodoWrite, TodoRead
|
||||
*/
|
||||
export const TodoListContent: React.FC<TodoListContentProps> = ({
|
||||
todos,
|
||||
isResult = false
|
||||
}) => {
|
||||
return <TodoList todos={todos} isResult={isResult} />;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MarkdownContent } from './MarkdownContent';
|
||||
export { FileListContent } from './FileListContent';
|
||||
export { TodoListContent } from './TodoListContent';
|
||||
export { TextContent } from './TextContent';
|
||||
84
src/components/chat/tools/components/DiffViewer.tsx
Normal file
84
src/components/chat/tools/components/DiffViewer.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface DiffViewerProps {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
filePath: string;
|
||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||
onFileClick?: () => void;
|
||||
badge?: string;
|
||||
badgeColor?: 'gray' | 'green';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable diff viewer component with consistent styling
|
||||
* Replaces duplicated diff display logic in Edit, Write, and result sections
|
||||
*/
|
||||
export const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
createDiff,
|
||||
onFileClick,
|
||||
badge = 'Diff',
|
||||
badgeColor = 'gray'
|
||||
}) => {
|
||||
const badgeClasses = badgeColor === 'green'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900/50 border border-gray-200/60 dark:border-gray-700/60 rounded-lg overflow-hidden shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/80 dark:to-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/60 backdrop-blur-sm">
|
||||
{onFileClick ? (
|
||||
<button
|
||||
onClick={onFileClick}
|
||||
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
|
||||
>
|
||||
{filePath}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-gray-700 dark:text-gray-300 truncate">
|
||||
{filePath}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${badgeClasses}`}>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="text-xs font-mono">
|
||||
{createDiff(oldContent, newContent).map((diffLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span
|
||||
className={`w-8 text-center border-r ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}`}
|
||||
>
|
||||
{diffLine.type === 'removed' ? '-' : '+'}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
}`}
|
||||
>
|
||||
{diffLine.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/components/chat/tools/components/FilePathButton.tsx
Normal file
44
src/components/chat/tools/components/FilePathButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FilePathButtonProps {
|
||||
filePath: string;
|
||||
onClick: () => void;
|
||||
variant?: 'button' | 'link';
|
||||
showFullPath?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable clickable file path component with consistent styling
|
||||
* Used across Edit, Write, and Read tool displays
|
||||
*/
|
||||
export const FilePathButton: React.FC<FilePathButtonProps> = ({
|
||||
filePath,
|
||||
onClick,
|
||||
variant = 'button',
|
||||
showFullPath = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const filename = filePath.split('/').pop() || filePath;
|
||||
const displayText = showFullPath ? filePath : filename;
|
||||
|
||||
if (variant === 'link') {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono transition-colors ${className}`}
|
||||
>
|
||||
{displayText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm ${className}`}
|
||||
>
|
||||
{displayText}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
134
src/components/chat/tools/components/OneLineDisplay.tsx
Normal file
134
src/components/chat/tools/components/OneLineDisplay.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
|
||||
|
||||
interface OneLineDisplayProps {
|
||||
icon?: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
secondary?: string;
|
||||
action?: ActionType;
|
||||
onAction?: () => void;
|
||||
colorScheme?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
};
|
||||
resultId?: string; // For jump-to-results
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified one-line display for simple tool inputs and results
|
||||
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
|
||||
*/
|
||||
export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
secondary,
|
||||
action = 'none',
|
||||
onAction,
|
||||
colorScheme = {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
secondary: 'text-gray-500 dark:text-gray-400'
|
||||
},
|
||||
resultId
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleAction = () => {
|
||||
if (action === 'copy' && value) {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else if (onAction) {
|
||||
onAction();
|
||||
}
|
||||
};
|
||||
|
||||
const renderActionButton = () => {
|
||||
if (action === 'none') return null;
|
||||
|
||||
if (action === 'copy') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors ml-1"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (action === 'open-file') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono transition-colors"
|
||||
>
|
||||
{value.split('/').pop()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (action === 'jump-to-results' && resultId) {
|
||||
return (
|
||||
<a
|
||||
href={`#${resultId}`}
|
||||
className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>Search results</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 text-sm flex items-center gap-2">
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<span className={`${colorScheme.primary} text-xs flex-shrink-0`}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<span className={colorScheme.primary}>{label}</span>
|
||||
)}
|
||||
|
||||
{/* Value - different rendering based on action type */}
|
||||
{action === 'open-file' ? (
|
||||
renderActionButton()
|
||||
) : (
|
||||
<span className={`${colorScheme.primary} ${action === 'none' ? '' : 'font-mono'}`}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Secondary text (e.g., description) */}
|
||||
{secondary && (
|
||||
<span className={`text-xs ${colorScheme.secondary} italic`}>
|
||||
({secondary})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action button (copy, jump) */}
|
||||
{action !== 'open-file' && renderActionButton()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
src/components/chat/tools/components/index.ts
Normal file
6
src/components/chat/tools/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { CollapsibleSection } from './CollapsibleSection';
|
||||
export { FilePathButton } from './FilePathButton';
|
||||
export { DiffViewer } from './DiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export * from './ContentRenderers';
|
||||
2
src/components/chat/tools/configs/index.ts
Normal file
2
src/components/chat/tools/configs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './toolConfigs';
|
||||
326
src/components/chat/tools/configs/toolConfigs.ts
Normal file
326
src/components/chat/tools/configs/toolConfigs.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Centralized tool configuration registry
|
||||
* Defines display behavior for all tool types using config-driven architecture
|
||||
*/
|
||||
|
||||
export interface ToolDisplayConfig {
|
||||
input: {
|
||||
type: 'one-line' | 'collapsible' | 'hidden';
|
||||
// One-line config
|
||||
icon?: string;
|
||||
label?: string;
|
||||
getValue?: (input: any) => string;
|
||||
getSecondary?: (input: any) => string | undefined;
|
||||
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
|
||||
colorScheme?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
};
|
||||
// Collapsible config
|
||||
title?: string | ((input: any) => string);
|
||||
defaultOpen?: boolean;
|
||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text';
|
||||
getContentProps?: (input: any, helpers?: any) => any;
|
||||
actionButton?: 'file-button' | 'none';
|
||||
};
|
||||
result?: {
|
||||
hidden?: boolean;
|
||||
hideOnSuccess?: boolean;
|
||||
type?: 'one-line' | 'collapsible' | 'special';
|
||||
// Special result handlers
|
||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message';
|
||||
getMessage?: (result: any) => string;
|
||||
getContentProps?: (result: any) => any;
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
// ============================================================================
|
||||
// COMMAND TOOLS
|
||||
// ============================================================================
|
||||
|
||||
Bash: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
icon: '$',
|
||||
getValue: (input) => input.command,
|
||||
getSecondary: (input) => input.description,
|
||||
action: 'copy',
|
||||
colorScheme: {
|
||||
primary: 'text-green-600 dark:text-green-400',
|
||||
secondary: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true,
|
||||
type: 'special' // Interactive prompts, cat -n output, etc.
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// FILE OPERATION TOOLS
|
||||
// ============================================================================
|
||||
|
||||
Read: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Read',
|
||||
getValue: (input) => input.file_path,
|
||||
action: 'open-file',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hidden: true // Read results not displayed
|
||||
}
|
||||
},
|
||||
|
||||
Edit: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'View edit diff for',
|
||||
defaultOpen: false,
|
||||
contentType: 'diff',
|
||||
actionButton: 'file-button',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: input.old_string,
|
||||
newContent: input.new_string,
|
||||
filePath: input.file_path,
|
||||
badge: 'Diff',
|
||||
badgeColor: 'gray'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
Write: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'Creating new file',
|
||||
defaultOpen: false,
|
||||
contentType: 'diff',
|
||||
actionButton: 'file-button',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: '',
|
||||
newContent: input.content,
|
||||
filePath: input.file_path,
|
||||
badge: 'New File',
|
||||
badgeColor: 'green'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
ApplyPatch: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'View patch diff for',
|
||||
defaultOpen: false,
|
||||
contentType: 'diff',
|
||||
actionButton: 'file-button',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: input.old_string,
|
||||
newContent: input.new_string,
|
||||
filePath: input.file_path,
|
||||
badge: 'Patch',
|
||||
badgeColor: 'gray'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH TOOLS
|
||||
// ============================================================================
|
||||
|
||||
Grep: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Grep',
|
||||
getValue: (input) => input.pattern,
|
||||
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
|
||||
action: 'jump-to-results',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
secondary: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'file-list',
|
||||
getContentProps: (result) => {
|
||||
const toolData = result.toolUseResult || {};
|
||||
return {
|
||||
files: toolData.filenames || [],
|
||||
title: toolData.filenames ?
|
||||
`Found ${toolData.numFiles || toolData.filenames.length} ${(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'}`
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Glob: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Glob',
|
||||
getValue: (input) => input.pattern,
|
||||
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
|
||||
action: 'jump-to-results',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
secondary: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'file-list',
|
||||
getContentProps: (result) => {
|
||||
const toolData = result.toolUseResult || {};
|
||||
return {
|
||||
files: toolData.filenames || [],
|
||||
title: toolData.filenames ?
|
||||
`Found ${toolData.numFiles || toolData.filenames.length} ${(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'}`
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// TODO TOOLS
|
||||
// ============================================================================
|
||||
|
||||
TodoWrite: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'Updating Todo List',
|
||||
defaultOpen: false,
|
||||
contentType: 'todo-list',
|
||||
getContentProps: (input) => ({
|
||||
todos: input.todos
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'success-message',
|
||||
getMessage: () => 'Todo list has been updated successfully'
|
||||
}
|
||||
},
|
||||
|
||||
TodoRead: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Read todo list',
|
||||
getValue: () => '',
|
||||
action: 'none'
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'todo-list',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
const content = String(result.content || '');
|
||||
let todos = null;
|
||||
if (content.startsWith('[')) {
|
||||
todos = JSON.parse(content);
|
||||
}
|
||||
return { todos, isResult: true };
|
||||
} catch (e) {
|
||||
return { todos: [], isResult: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// PLAN TOOLS
|
||||
// ============================================================================
|
||||
|
||||
exit_plan_mode: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'View implementation plan',
|
||||
defaultOpen: true,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (input) => ({
|
||||
content: input.plan?.replace(/\\n/g, '\n') || input.plan
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
let parsed = result.content;
|
||||
if (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return {
|
||||
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
|
||||
};
|
||||
} catch (e) {
|
||||
return { content: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// DEFAULT FALLBACK
|
||||
// ============================================================================
|
||||
|
||||
Default: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'View input parameters',
|
||||
defaultOpen: false,
|
||||
contentType: 'text',
|
||||
getContentProps: (input) => ({
|
||||
content: typeof input === 'string' ? input : JSON.stringify(input, null, 2),
|
||||
format: 'code'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'text',
|
||||
getContentProps: (result) => ({
|
||||
content: String(result.content || ''),
|
||||
format: 'plain'
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration for a tool, with fallback to default
|
||||
*/
|
||||
export function getToolConfig(toolName: string): ToolDisplayConfig {
|
||||
return TOOL_CONFIGS[toolName] || TOOL_CONFIGS.Default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool result should be hidden
|
||||
*/
|
||||
export function shouldHideToolResult(toolName: string, toolResult: any): boolean {
|
||||
const config = getToolConfig(toolName);
|
||||
|
||||
if (!config.result) return false;
|
||||
|
||||
// Always hidden
|
||||
if (config.result.hidden) return true;
|
||||
|
||||
// Hide on success only
|
||||
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
40
src/components/chat/tools/configs/types.ts
Normal file
40
src/components/chat/tools/configs/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ToolResult } from '../../types';
|
||||
|
||||
export type ToolCategory = 'command' | 'file-operation' | 'search' | 'todo' | 'plan' | 'default';
|
||||
|
||||
export interface ToolConfig {
|
||||
/** Tool display name */
|
||||
displayName: string;
|
||||
|
||||
/** Tool category for grouping and styling */
|
||||
category: ToolCategory;
|
||||
|
||||
/** Icon identifier (can be emoji or icon name) */
|
||||
icon?: string;
|
||||
|
||||
/** Whether to show minimized display by default */
|
||||
minimized?: boolean;
|
||||
|
||||
/** Name of the renderer component to use */
|
||||
renderer: string;
|
||||
|
||||
/** Whether to hide successful results (show only errors) */
|
||||
hideSuccessfulResult?: boolean;
|
||||
|
||||
/** Whether this tool requires file system access */
|
||||
requiresFileAccess?: boolean;
|
||||
|
||||
/** Whether this tool supports copy to clipboard */
|
||||
supportsCopy?: boolean;
|
||||
|
||||
/** Custom function to determine if result should be hidden */
|
||||
shouldHideResult?: (result: ToolResult | null) => boolean;
|
||||
|
||||
/** Color scheme for the tool display */
|
||||
colorScheme?: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolConfigRegistry = Record<string, ToolConfig>;
|
||||
4
src/components/chat/tools/index.ts
Normal file
4
src/components/chat/tools/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ToolRenderer } from './ToolRenderer';
|
||||
export { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';
|
||||
export * from './components';
|
||||
export * from './configs/types';
|
||||
Reference in New Issue
Block a user