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 - - - -
-
-
- - - 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: - - - -
-
-
- - - 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{' '} - -
- ); - } - } 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} + + )} + + {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 ( + + ); + } + + return ( + + ); +}; 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 ( + + ); + } + + if (action === 'open-file') { + return ( + + ); + } + + 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';