refactor: tool components

This commit is contained in:
simosmik
2026-02-09 09:55:40 +00:00
committed by Haileyesus
parent 4d55945c5d
commit c95aa04c85
18 changed files with 1669 additions and 414 deletions

View File

@@ -11,6 +11,7 @@ import { Markdown } from '../markdown/Markdown';
import { formatUsageLimitText } from '../utils/chatFormatting'; import { formatUsageLimitText } from '../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../utils/chatPermissions'; import { getClaudePermissionSuggestion } from '../utils/chatPermissions';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../tools';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -45,6 +46,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const permissionSuggestion = getClaudePermissionSuggestion(message, provider); const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
React.useEffect(() => { React.useEffect(() => {
setPermissionGrantState('idle'); setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]); }, [permissionSuggestion?.entry, message.toolId]);
@@ -232,423 +234,28 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</button> </button>
)} )}
</div> </div>
{message.toolInput && message.toolName === 'Edit' && (() => { {/* All tool input rendering now handled by ToolRenderer */}
try { {message.toolInput && (() => {
const input = JSON.parse(message.toolInput); // Use new ToolRenderer for all tools (config-driven)
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
}
return ( return (
<details className="relative mt-3 group/params" open={autoExpandTools}> <ToolRenderer
<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"> toolName={message.toolName}
<svg className="w-4 h-4 transition-transform duration-200 group-open/params:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"> toolInput={message.toolInput}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> mode="input"
</svg> onFileOpen={onFileOpen}
View input parameters createDiff={createDiff}
</summary> selectedProject={selectedProject}
<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"> autoExpandTools={autoExpandTools}
{message.toolInput} showRawParameters={showRawParameters}
</pre> rawToolInput={message.toolInput}
</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>
); );
})()} })()}
{/* Tool Result Section */} {/* Tool Result Section */}
{message.toolResult && (() => { {message.toolResult && (() => {
// Hide tool results for Edit/Write/Bash unless there's an error // Use config to determine if result should be hidden
const shouldHideResult = !message.toolResult.isError && if (shouldHideToolResult(message.toolName, message.toolResult)) {
(message.toolName === 'Edit' || message.toolName === 'Write' || message.toolName === 'ApplyPatch' || message.toolName === 'Bash');
if (shouldHideResult) {
return null; return null;
} }
@@ -737,9 +344,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Special handling for exit_plan_mode tool results // Special handling for exit_plan_mode tool results
if (message.toolName === 'exit_plan_mode') { if (message.toolName === 'exit_plan_mode') {
try { try {
// The content should be JSON with a "plan" field // Content might already be an object or a JSON string
const parsed = JSON.parse(content); let parsed;
if (parsed.plan) { 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 // Replace escaped newlines with actual newlines
const planContent = parsed.plan.replace(/\\n/g, '\n'); const planContent = parsed.plan.replace(/\\n/g, '\n');
return ( return (
@@ -754,6 +367,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
); );
} }
} catch (e) { } catch (e) {
console.error('Failed to parse exit_plan_mode result:', e);
// Fall through to regular handling // Fall through to regular handling
} }
} }

View 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 |

View 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;
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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} />;
};

View File

@@ -0,0 +1,4 @@
export { MarkdownContent } from './MarkdownContent';
export { FileListContent } from './FileListContent';
export { TodoListContent } from './TodoListContent';
export { TextContent } from './TextContent';

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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';

View File

@@ -0,0 +1,2 @@
export * from './types';
export * from './toolConfigs';

View 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;
}

View 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>;

View File

@@ -0,0 +1,4 @@
export { ToolRenderer } from './ToolRenderer';
export { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';
export * from './components';
export * from './configs/types';