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