diff --git a/src/components/chat/messages/MessageComponent.tsx b/src/components/chat/messages/MessageComponent.tsx
index e8e5581..4161adf 100644
--- a/src/components/chat/messages/MessageComponent.tsx
+++ b/src/components/chat/messages/MessageComponent.tsx
@@ -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
)}
- {message.toolInput && message.toolName === 'Edit' && (() => {
- try {
- const input = JSON.parse(message.toolInput);
- if (input.file_path && input.old_string && input.new_string) {
- return (
-
-
-
-
-
-
- View edit diff for
-
- {
- 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()}
-
-
-
-
-
- {
- 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}
-
-
- Diff
-
-
-
- {createDiff(input.old_string, input.new_string).map((diffLine, i) => (
-
-
- {diffLine.type === 'removed' ? '-' : '+'}
-
-
- {diffLine.content}
-
-
- ))}
-
-
- {showRawParameters && (
-
-
-
-
-
- View raw parameters
-
-
- {message.toolInput}
-
-
- )}
-
-
- );
- }
- } 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 (
-
-
-
-
-
- View input parameters
-
-
- {message.toolInput}
-
-
- );
- })()}
- {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 (
-
-
-
-
-
-
- 📄
- Creating new file:
-
- {
- 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()}
-
-
-
-
-
- {
- 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}
-
-
- New File
-
-
-
- {createDiff('', input.content).map((diffLine, i) => (
-
-
- {diffLine.type === 'removed' ? '-' : '+'}
-
-
- {diffLine.content}
-
-
- ))}
-
-
- {showRawParameters && (
-
-
-
-
-
- View raw parameters
-
-
- {message.toolInput}
-
-
- )}
-
-
- );
- }
- } 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 (
-
-
-
-
-
-
- ✓
- Updating Todo List
-
-
-
-
- {showRawParameters && (
-
-
-
-
-
- View raw parameters
-
-
- {message.toolInput}
-
-
- )}
-
-
- );
- }
- } catch (e) {
- // Fall back to regular display
- }
- }
-
- // Special handling for Bash tool
- if (message.toolName === 'Bash') {
- try {
- const input = JSON.parse(message.toolInput);
- return (
-
-
- $
- {input.command}
-
- {input.description && (
-
- {input.description}
-
- )}
-
- );
- } 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 (
-
- Read{' '}
- 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}
-
-
- );
- }
- } 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 (
-
-
-
-
-
- 📋 View implementation plan
-
-
- {planContent}
-
-
- );
- }
- } catch (e) {
- // Fall back to regular display
- }
- }
-
- // Regular tool input display for other tools
- return (
-
-
-
-
-
- View input parameters
-
-
- {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
}
}
diff --git a/src/components/chat/tools/README.md b/src/components/chat/tools/README.md
new file mode 100644
index 0000000..efff8db
--- /dev/null
+++ b/src/components/chat/tools/README.md
@@ -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
+ ...} // Action callback
+ colorScheme={{ // Optional color customization
+ primary: "text-...",
+ secondary: "text-..."
+ }}
+/>
+```
+
+#### **CollapsibleDisplay** - For complex tools
+Used by: Edit, Write, Plan, TodoWrite, Grep/Glob results
+
+```tsx
+ } // 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
+ ...}
+ 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 |
+
diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx
new file mode 100644
index 0000000..9dfe54e
--- /dev/null
+++ b/src/components/chat/tools/ToolRenderer.tsx
@@ -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 = ({
+ 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 (
+
+ );
+ }
+
+ // ============================================================================
+ // 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 = (
+ onFileOpen?.(contentProps.filePath)}
+ />
+ );
+ break;
+
+ case 'markdown':
+ contentComponent = ;
+ break;
+
+ case 'file-list':
+ contentComponent = (
+
+ );
+ break;
+
+ case 'todo-list':
+ if (!contentProps.todos || contentProps.todos.length === 0) {
+ contentComponent = null;
+ break;
+ }
+ contentComponent = (
+
+ );
+ break;
+
+ case 'text':
+ contentComponent = (
+
+ );
+ break;
+
+ case 'success-message':
+ const message = displayConfig.getMessage?.(parsedData) || 'Success';
+ contentComponent = (
+
+ {message}
+
+ );
+ break;
+
+ default:
+ contentComponent = (
+ Unknown content type: {displayConfig.contentType}
+ );
+ }
+
+ // 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 = (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ // ============================================================================
+ // SPECIAL / HIDDEN
+ // ============================================================================
+ return null;
+};
diff --git a/src/components/chat/tools/components/CollapsibleDisplay.tsx b/src/components/chat/tools/components/CollapsibleDisplay.tsx
new file mode 100644
index 0000000..b4692e3
--- /dev/null
+++ b/src/components/chat/tools/components/CollapsibleDisplay.tsx
@@ -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 = ({
+ 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 Unknown content type: {contentType}
;
+ }
+ };
+
+ return (
+
+ {/* Main content */}
+ {renderContent()}
+
+ {/* Optional raw parameters viewer */}
+ {showRawParameters && rawContent && (
+
+
+
+
+
+ View raw parameters
+
+
+ {rawContent}
+
+
+ )}
+
+ );
+};
diff --git a/src/components/chat/tools/components/CollapsibleSection.tsx b/src/components/chat/tools/components/CollapsibleSection.tsx
new file mode 100644
index 0000000..c5d2bfd
--- /dev/null
+++ b/src/components/chat/tools/components/CollapsibleSection.tsx
@@ -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 = ({
+ title,
+ open = false,
+ action,
+ children,
+ className = ''
+}) => {
+ return (
+
+
+
+
+
+
+ {title}
+
+ {action}
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/FileListContent.tsx b/src/components/chat/tools/components/ContentRenderers/FileListContent.tsx
new file mode 100644
index 0000000..4135554
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/FileListContent.tsx
@@ -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 = ({
+ files,
+ onFileClick,
+ title
+}) => {
+ const fileCount = files.length;
+
+ return (
+
+ {title && (
+
+
+ {title || `Found ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}
+
+
+ )}
+
+ {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 (
+
+ {/* File icon */}
+
+
+
+
+ {/* File path */}
+
+
+ {fileName}
+
+ {dirPath && (
+
+ {dirPath}
+
+ )}
+
+
+ {/* Chevron on hover */}
+
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx b/src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx
new file mode 100644
index 0000000..2d6995c
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx
@@ -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 = ({
+ content,
+ className = 'mt-3 prose prose-sm max-w-none dark:prose-invert'
+}) => {
+ return (
+
+ {content}
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/TextContent.tsx b/src/components/chat/tools/components/ContentRenderers/TextContent.tsx
new file mode 100644
index 0000000..24aa543
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/TextContent.tsx
@@ -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 = ({
+ 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 (
+
+ {formattedJson}
+
+ );
+ }
+
+ if (format === 'code') {
+ return (
+
+ {content}
+
+ );
+ }
+
+ // Plain text
+ return (
+
+ {content}
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx b/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx
new file mode 100644
index 0000000..81b01d5
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx
@@ -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 = ({
+ todos,
+ isResult = false
+}) => {
+ return ;
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/index.ts b/src/components/chat/tools/components/ContentRenderers/index.ts
new file mode 100644
index 0000000..a74d879
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/index.ts
@@ -0,0 +1,4 @@
+export { MarkdownContent } from './MarkdownContent';
+export { FileListContent } from './FileListContent';
+export { TodoListContent } from './TodoListContent';
+export { TextContent } from './TextContent';
diff --git a/src/components/chat/tools/components/DiffViewer.tsx b/src/components/chat/tools/components/DiffViewer.tsx
new file mode 100644
index 0000000..b55780e
--- /dev/null
+++ b/src/components/chat/tools/components/DiffViewer.tsx
@@ -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 = ({
+ 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 (
+
+ {/* Header */}
+
+ {onFileClick ? (
+
+ {filePath}
+
+ ) : (
+
+ {filePath}
+
+ )}
+
+ {badge}
+
+
+
+ {/* Diff content */}
+
+ {createDiff(oldContent, newContent).map((diffLine, i) => (
+
+
+ {diffLine.type === 'removed' ? '-' : '+'}
+
+
+ {diffLine.content}
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/FilePathButton.tsx b/src/components/chat/tools/components/FilePathButton.tsx
new file mode 100644
index 0000000..c52d0a3
--- /dev/null
+++ b/src/components/chat/tools/components/FilePathButton.tsx
@@ -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 = ({
+ filePath,
+ onClick,
+ variant = 'button',
+ showFullPath = false,
+ className = ''
+}) => {
+ const filename = filePath.split('/').pop() || filePath;
+ const displayText = showFullPath ? filePath : filename;
+
+ if (variant === 'link') {
+ return (
+
+ {displayText}
+
+ );
+ }
+
+ return (
+
+ {displayText}
+
+ );
+};
diff --git a/src/components/chat/tools/components/OneLineDisplay.tsx b/src/components/chat/tools/components/OneLineDisplay.tsx
new file mode 100644
index 0000000..8db63d4
--- /dev/null
+++ b/src/components/chat/tools/components/OneLineDisplay.tsx
@@ -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 = ({
+ 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 (
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+ }
+
+ if (action === 'open-file') {
+ return (
+
+ {value.split('/').pop()}
+
+ );
+ }
+
+ if (action === 'jump-to-results' && resultId) {
+ return (
+
+ Search results
+
+
+
+
+ );
+ }
+
+ return null;
+ };
+
+ return (
+
+ {/* Icon */}
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {/* Label */}
+ {label && (
+ {label}
+ )}
+
+ {/* Value - different rendering based on action type */}
+ {action === 'open-file' ? (
+ renderActionButton()
+ ) : (
+
+ {value}
+
+ )}
+
+ {/* Secondary text (e.g., description) */}
+ {secondary && (
+
+ ({secondary})
+
+ )}
+
+ {/* Action button (copy, jump) */}
+ {action !== 'open-file' && renderActionButton()}
+
+ );
+};
diff --git a/src/components/chat/tools/components/index.ts b/src/components/chat/tools/components/index.ts
new file mode 100644
index 0000000..fa6a07a
--- /dev/null
+++ b/src/components/chat/tools/components/index.ts
@@ -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';
diff --git a/src/components/chat/tools/configs/index.ts b/src/components/chat/tools/configs/index.ts
new file mode 100644
index 0000000..021d41d
--- /dev/null
+++ b/src/components/chat/tools/configs/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from './toolConfigs';
diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts
new file mode 100644
index 0000000..cd70425
--- /dev/null
+++ b/src/components/chat/tools/configs/toolConfigs.ts
@@ -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 = {
+ // ============================================================================
+ // 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;
+}
diff --git a/src/components/chat/tools/configs/types.ts b/src/components/chat/tools/configs/types.ts
new file mode 100644
index 0000000..a8edd8e
--- /dev/null
+++ b/src/components/chat/tools/configs/types.ts
@@ -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;
diff --git a/src/components/chat/tools/index.ts b/src/components/chat/tools/index.ts
new file mode 100644
index 0000000..645a5aa
--- /dev/null
+++ b/src/components/chat/tools/index.ts
@@ -0,0 +1,4 @@
+export { ToolRenderer } from './ToolRenderer';
+export { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';
+export * from './components';
+export * from './configs/types';