diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index 59693328b1..edd726c4ac 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -7,6 +7,7 @@ import type { StoredSessionUpdate } from '../../../shared/types' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { ChevronDown } from 'lucide-react' +import { ToolCallItem, type ToolCall } from './ToolCallItem' interface ChatViewProps { updates: StoredSessionUpdate[] @@ -73,15 +74,6 @@ interface Message { toolCalls: ToolCall[] } -interface ToolCall { - id: string - title: string - status: string - kind?: string - input?: string - output?: string -} - function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] { const messages: Message[] = [] let currentAssistantContent = '' @@ -237,7 +229,7 @@ function MessageBubble({ message }: MessageBubbleProps) { {/* Tool calls */} {message.toolCalls.map((tc) => ( - + ))} {/* Text content with markdown */} @@ -369,157 +361,6 @@ function ThoughtBlock({ text }: { text: string }) { ) } -// Tool call line - inline display -function ToolCallLine({ toolCall }: { toolCall: ToolCall }) { - const { icon, action, detail } = parseToolCall(toolCall) - - const isRunning = toolCall.status === 'running' || toolCall.status === 'in_progress' || toolCall.status === 'pending' - const isFailed = toolCall.status === 'failed' - - return ( -
- {/* Icon */} - {icon} - - {/* Action */} - {action} - - {/* Detail in code pill */} - {detail && ( - - {detail} - - )} - - {/* Running indicator */} - {isRunning && } -
- ) -} - -interface ParsedToolCall { - icon: string - action: string - detail?: string -} - -function parseToolCall(toolCall: ToolCall): ParsedToolCall { - const title = toolCall.title?.toLowerCase() || '' - const kind = toolCall.kind?.toLowerCase() || '' - - // Try to extract file path from input - let filePath: string | undefined - if (toolCall.input) { - try { - const parsed = JSON.parse(toolCall.input) - filePath = parsed.file_path || parsed.path || parsed.filePath || parsed.pattern - } catch { - // Not JSON, might be a direct path - if (toolCall.input.startsWith('/') || toolCall.input.includes('.')) { - filePath = toolCall.input.split('\n')[0].trim() - } - } - } - - // Determine icon and action based on kind/title - if (kind === 'search' || title.includes('glob') || title.includes('search') || title.includes('grep')) { - return { - icon: '◎', - action: title.includes('glob') ? 'Search files' : 'Search', - detail: filePath || extractPathFromTitle(toolCall.title), - } - } - - if (title.includes('list') || title.startsWith('ls')) { - return { - icon: '▤', - action: 'List', - detail: extractPathFromTitle(toolCall.title), - } - } - - if (title.includes('read')) { - return { - icon: '◔', - action: 'Read', - detail: filePath || extractPathFromTitle(toolCall.title), - } - } - - if (title.includes('write') || title.includes('create')) { - // Try to get line count from output - let lineCount = '' - if (toolCall.output) { - const lines = toolCall.output.split('\n').length - if (lines > 1) lineCount = `${lines} lines` - } - return { - icon: '▤', - action: lineCount ? `Write ${lineCount}` : 'Write', - detail: filePath || extractPathFromTitle(toolCall.title), - } - } - - if (title.includes('edit') || title.includes('replace')) { - return { - icon: '✎', - action: 'Edit', - detail: filePath || extractPathFromTitle(toolCall.title), - } - } - - if (title.startsWith('run') || kind === 'bash' || kind === 'shell') { - const cmd = extractCommandFromTitle(toolCall.title) - return { - icon: '>_', - action: getCommandDescription(toolCall.title), - detail: cmd, - } - } - - // Default - return { - icon: '◇', - action: toolCall.title || toolCall.kind || 'Tool', - detail: filePath, - } -} - -function extractPathFromTitle(title?: string): string | undefined { - if (!title) return undefined - // Look for path-like strings - const match = title.match(/\/[\w\-./]+/) - return match ? match[0] : undefined -} - -function extractCommandFromTitle(title?: string): string | undefined { - if (!title) return undefined - // Remove "Run " prefix and get the command - if (title.toLowerCase().startsWith('run ')) { - const cmd = title.slice(4).trim() - // Truncate if too long - return cmd.length > 50 ? cmd.slice(0, 47) + '...' : cmd - } - return undefined -} - -function getCommandDescription(title?: string): string { - if (!title) return 'Run command' - const lower = title.toLowerCase() - - if (lower.includes('mkdir')) return 'Create directory' - if (lower.includes('rm ')) return 'Remove' - if (lower.includes('mv ')) return 'Move' - if (lower.includes('cp ')) return 'Copy' - if (lower.includes('npm') || lower.includes('pnpm') || lower.includes('yarn')) return 'Package manager' - if (lower.includes('git')) return 'Git' - if (lower.includes('python')) return 'Run Python' - if (lower.includes('node')) return 'Run Node' - if (lower.includes('perl')) return 'Run Perl' - - return 'Run command' -} - function LoadingDots() { return ( diff --git a/src/renderer/src/components/ToolCallItem.tsx b/src/renderer/src/components/ToolCallItem.tsx new file mode 100644 index 0000000000..e0b453a9f2 --- /dev/null +++ b/src/renderer/src/components/ToolCallItem.tsx @@ -0,0 +1,135 @@ +/** + * Tool call item component - displays tool calls with expandable details + */ +import { useState } from 'react' +import { ChevronRight } from 'lucide-react' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' + +export interface ToolCall { + id: string + title: string + status: string + kind?: string + input?: string + output?: string +} + +// Status dot component - displays tool call status with color and animation +function StatusDot({ status }: { status: string }) { + const statusStyles: Record = { + pending: 'bg-[var(--tool-pending)]', + running: 'bg-[var(--tool-running)] animate-[glow-pulse_2s_ease-in-out_infinite]', + in_progress: 'bg-[var(--tool-running)] animate-[glow-pulse_2s_ease-in-out_infinite]', + completed: 'bg-[var(--tool-success)]', + failed: 'bg-[var(--tool-error)]', + } + + return ( + + ) +} + +// Tool call details - shows input and output separated by a line +function ToolCallDetails({ toolCall }: { toolCall: ToolCall }) { + return ( +
+ {/* Input */} + {toolCall.input && ( +
+
+            {formatJson(toolCall.input)}
+          
+
+ )} + + {/* Separator */} + {toolCall.input && toolCall.output && ( +
+ )} + + {/* Output */} + {toolCall.output && ( +
+
+            {toolCall.output}
+          
+
+ )} +
+ ) +} + +// Format JSON string for display +function formatJson(input: string): string { + try { + const parsed = JSON.parse(input) + return JSON.stringify(parsed, null, 2) + } catch { + return input + } +} + +// Tool call item - expandable display with input/output details +export function ToolCallItem({ toolCall }: { toolCall: ToolCall }) { + const [isOpen, setIsOpen] = useState(false) + + const hasDetails = toolCall.input || toolCall.output + const isFailed = toolCall.status === 'failed' + const kind = toolCall.kind || 'tool' + + return ( + + + {/* Status dot */} + + + {/* Kind */} + + {kind} + + + {/* Title (file path etc) - truncate */} + {toolCall.title && ( + + {toolCall.title} + + )} + + {/* Expand indicator */} + {hasDetails && ( + + )} + + + {hasDetails && ( + + + + )} + + ) +} diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css index 67f5f4dbc6..175547256c 100644 --- a/src/renderer/src/styles/index.css +++ b/src/renderer/src/styles/index.css @@ -122,6 +122,12 @@ body { --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); + + /* Tool call status colors */ + --tool-pending: oklch(0.55 0.05 250); + --tool-running: oklch(0.6 0.18 250); + --tool-success: oklch(0.72 0.12 145); + --tool-error: oklch(0.65 0.2 25); } .dark { @@ -156,6 +162,12 @@ body { --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.552 0.016 285.938); + + /* Tool call status colors */ + --tool-pending: oklch(0.5 0.08 250); + --tool-running: oklch(0.65 0.2 250); + --tool-success: oklch(0.65 0.15 145); + --tool-error: oklch(0.7 0.2 22); } @layer base { @@ -166,3 +178,13 @@ body { @apply bg-background text-foreground; } } + +/* Tool call status glow animation */ +@keyframes glow-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 var(--tool-running); + } + 50% { + box-shadow: 0 0 0 3px oklch(0.6 0.2 250 / 0); + } +}