From f6d99aff78a5e7c1c8a22cf3f34f60c3092f3e3c Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Wed, 14 Jan 2026 04:24:29 +0800 Subject: [PATCH] feat: redesign tool calls to match reference UI style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flat list style instead of card boxes - Inline thinking display with ⊛ icon + truncated text in pill - Tool calls show: icon + action verb + detail in code pill - Smart parsing of tool calls to extract meaningful info - Action verbs: "Write 71 lines", "Create directory", "Search files" - Icons: ▤ for files, >_ for commands, ◎ for search, ✎ for edit - User messages in dark bubble, assistant messages flat - Loading dots for running tools - Red text for failed tools Co-Authored-By: Claude Opus 4.5 --- src/renderer/src/components/ChatView.tsx | 341 ++++++++++++----------- 1 file changed, 176 insertions(+), 165 deletions(-) diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index dea247c9e8..fad7b767b9 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -1,7 +1,7 @@ /** * Chat view component - displays messages and tool calls */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import type { StoredSessionUpdate } from '../../../shared/types' interface ChatViewProps { @@ -215,192 +215,203 @@ interface MessageBubbleProps { function MessageBubble({ message }: MessageBubbleProps) { const isUser = message.role === 'user' - return ( -
-
- {/* Thought (agent reasoning) */} - {message.thought && ( -
- {message.thought} -
- )} - - {/* Tool calls */} - {message.toolCalls.length > 0 && ( -
- {message.toolCalls.map((tc) => ( - - ))} -
- )} - - {/* Text content */} - {message.content && ( -
{message.content}
- )} + // User message - bubble style + if (isUser) { + return ( +
+
+ {message.content} +
-
- ) -} - -interface ToolCallCardProps { - toolCall: ToolCall -} - -function ToolCallCard({ toolCall }: ToolCallCardProps) { - const [expanded, setExpanded] = useState(false) - - // Get icon based on tool kind - const icon = getToolIcon(toolCall.kind, toolCall.title) - - // Get clean title - const cleanTitle = getCleanTitle(toolCall.title, toolCall.kind) - - // Status styling - const statusConfig = { - completed: { icon: '✓', color: 'text-green-500', bg: 'bg-green-500/10' }, - failed: { icon: '✗', color: 'text-red-500', bg: 'bg-red-500/10' }, - running: { icon: null, color: 'text-blue-500', bg: 'bg-blue-500/10' }, - in_progress: { icon: null, color: 'text-blue-500', bg: 'bg-blue-500/10' }, - pending: { icon: '○', color: 'text-yellow-500', bg: 'bg-yellow-500/10' }, + ) } - const status = statusConfig[toolCall.status as keyof typeof statusConfig] || statusConfig.pending - - const hasDetails = toolCall.input || toolCall.output + // Assistant message - flat list style return ( -
- {/* Header - always visible */} -
hasDetails && setExpanded(!expanded)} - > - {/* Tool icon */} - {icon} +
+ {/* Thought */} + {message.thought && ( + + )} - {/* Title */} - {cleanTitle} + {/* Tool calls */} + {message.toolCalls.map((tc) => ( + + ))} - {/* Status */} - - {status.icon ? ( - {status.icon} - ) : ( - - )} - {toolCall.status} - - - {/* Expand icon */} - {hasDetails && ( - - ▼ - - )} -
- - {/* Details - collapsible */} - {expanded && hasDetails && ( -
- {toolCall.input && ( -
-
Input
-
-                {toolCall.input}
-              
-
- )} - {toolCall.output && ( -
-
Output
-
-                {toolCall.output.slice(0, 500)}
-                {toolCall.output.length > 500 && '...'}
-              
-
- )} + {/* Text content */} + {message.content && ( +
+ {message.content}
)}
) } -function getToolIcon(kind?: string, title?: string): string { - const lowerKind = kind?.toLowerCase() || '' - const lowerTitle = title?.toLowerCase() || '' - - // Match by kind first - if (lowerKind.includes('search') || lowerKind.includes('grep') || lowerKind.includes('glob')) { - return '🔍' - } - if (lowerKind.includes('file') || lowerKind.includes('read') || lowerKind.includes('write')) { - return '📄' - } - if (lowerKind.includes('edit')) { - return '✏️' - } - if (lowerKind.includes('bash') || lowerKind.includes('shell') || lowerKind.includes('command')) { - return '⌨️' - } - - // Match by title - if (lowerTitle.includes('list') || lowerTitle.includes('ls')) { - return '📁' - } - if (lowerTitle.includes('read')) { - return '📖' - } - if (lowerTitle.includes('write') || lowerTitle.includes('create')) { - return '📝' - } - if (lowerTitle.includes('edit') || lowerTitle.includes('replace')) { - return '✏️' - } - if (lowerTitle.includes('run') || lowerTitle.includes('exec') || lowerTitle.includes('bash')) { - return '⌨️' - } - if (lowerTitle.includes('search') || lowerTitle.includes('find') || lowerTitle.includes('grep')) { - return '🔍' - } - if (lowerTitle.includes('glob')) { - return '🔍' - } - - return '🔧' +// Thought line - inline display +function ThoughtLine({ text }: { text: string }) { + const truncated = text.length > 80 ? text.slice(0, 77) + '...' : text + return ( +
+ + Thinking + + {truncated} + +
+ ) } -function getCleanTitle(title?: string, kind?: string): string { - if (!title) return kind || 'Tool Call' +// Tool call line - inline display +function ToolCallLine({ toolCall }: { toolCall: ToolCall }) { + const { icon, action, detail } = parseToolCall(toolCall) - // If title is too long or looks like a command, try to extract meaningful part - const cleanTitle = title.trim() + const isRunning = toolCall.status === 'running' || toolCall.status === 'in_progress' || toolCall.status === 'pending' + const isFailed = toolCall.status === 'failed' - // If it starts with "Run " and has a long command, shorten it - if (cleanTitle.startsWith('Run ') && cleanTitle.length > 50) { - const cmd = cleanTitle.slice(4).split(' ')[0] - return `Run ${cmd}` + 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() + } + } } - // If it's a file path, show just the filename - if (cleanTitle.includes('/') && !cleanTitle.includes(' ')) { - const parts = cleanTitle.split('/') - return parts[parts.length - 1] || cleanTitle + // 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), + } } - // Truncate if too long - if (cleanTitle.length > 60) { - return cleanTitle.slice(0, 57) + '...' + if (title.includes('list') || title.startsWith('ls')) { + return { + icon: '▤', + action: 'List', + detail: extractPathFromTitle(toolCall.title), + } } - return cleanTitle + 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() {