From 226850e446ddc96ed98fa58efc464e0ea5dc1fd7 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 12 Feb 2026 10:21:48 +0100 Subject: [PATCH] feat: show tool usage in the ai chat --- src/main/engine/OpenCodeManager.ts | 26 ++++- .../components/ChatPanel/ChatPanel.css | 51 ++++++++ .../components/ChatPanel/ChatPanel.tsx | 110 +++++++++++++++++- 3 files changed, 177 insertions(+), 10 deletions(-) diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 0ac8050..3f981b6 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -300,9 +300,10 @@ export class OpenCodeManager { systemPrompt, dbMessages, abortController.signal, - { onDelta } + { onDelta, onToolCall, onToolResult } ); fullResponse = result.content; + toolCallsCollected.push(...result.toolCalls); } console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); } catch (error) { @@ -517,8 +518,12 @@ export class OpenCodeManager { systemPrompt: string, dbMessages: Array<{ role: string; content?: string }>, signal: AbortSignal, - callbacks: { onDelta?: (delta: string) => void } - ): Promise<{ content: string }> { + callbacks: { + onDelta?: (delta: string) => void; + onToolCall?: (toolCall: { name: string; args: unknown }) => void; + onToolResult?: (result: { name: string; result: unknown }) => void; + } + ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { // Build OpenAI-format messages const messages: Array> = [ { role: 'system', content: systemPrompt }, @@ -542,6 +547,7 @@ export class OpenCodeManager { })); let accumulatedText = ''; + const allToolCalls: Array<{ name: string; args: unknown }> = []; const MAX_TOOL_ROUNDS = 10; let round = 0; @@ -614,7 +620,7 @@ export class OpenCodeManager { // If no tool calls, we're done if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) { console.log('[OpenCodeManager:OpenAI] Done. Accumulated text length:', accumulatedText.length); - return { content: accumulatedText }; + return { content: accumulatedText, toolCalls: allToolCalls }; } // Add assistant message (with tool_calls) to conversation @@ -624,8 +630,18 @@ export class OpenCodeManager { for (const toolCall of choice.message.tool_calls) { const toolName = toolCall.function.name; const toolArgs = JSON.parse(toolCall.function.arguments || '{}'); + + allToolCalls.push({ name: toolName, args: toolArgs }); + if (callbacks.onToolCall) { + callbacks.onToolCall({ name: toolName, args: toolArgs }); + } + const result = await this.executeTool(toolName, toolArgs); + if (callbacks.onToolResult) { + callbacks.onToolResult({ name: toolName, result }); + } + messages.push({ role: 'tool', content: JSON.stringify(result), @@ -636,7 +652,7 @@ export class OpenCodeManager { // Hit max rounds const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.'; - return { content: fallbackText }; + return { content: fallbackText, toolCalls: allToolCalls }; } /** diff --git a/src/renderer/components/ChatPanel/ChatPanel.css b/src/renderer/components/ChatPanel/ChatPanel.css index 4db8b33..103f2c0 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.css +++ b/src/renderer/components/ChatPanel/ChatPanel.css @@ -523,3 +523,54 @@ .chat-message-text em { font-style: italic; } + +/* Tool usage markers */ +.tool-markers { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; + padding: 8px 10px; + background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1)); + border-left: 3px solid var(--vscode-textLink-foreground, #3794ff); + border-radius: 0 4px 4px 0; + font-size: 12px; +} + +.tool-marker { + display: flex; + align-items: center; + gap: 6px; + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} + +.tool-marker.pending .tool-marker-icon { + color: var(--vscode-charts-yellow, #cca700); + animation: tool-pulse 1.2s ease-in-out infinite; +} + +.tool-marker.completed .tool-marker-icon { + color: var(--vscode-charts-green, #89d185); +} + +.tool-marker-name { + font-family: var(--vscode-editor-font-family, monospace); + font-weight: 600; + color: var(--vscode-foreground); +} + +.tool-marker-args { + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +@keyframes tool-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index 3787dd0..b43bedf 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -13,6 +13,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { const [inputValue, setInputValue] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); + const [toolEvents, setToolEvents] = useState>([]); const [availableModels, setAvailableModels] = useState([]); const [showModelSelector, setShowModelSelector] = useState(false); const [needsApiKey, setNeedsApiKey] = useState(false); @@ -22,6 +23,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { const messagesEndRef = useRef(null); const inputRef = useRef(null); const streamingRef = useRef(''); + const toolEventsRef = useRef>([]); // Scroll to bottom when messages change const scrollToBottom = useCallback(() => { @@ -72,6 +74,25 @@ export const ChatPanel: React.FC = ({ conversationId }) => { } }); + const unsubToolCall = window.electronAPI?.chat.onToolCall((data) => { + console.log('[ChatPanel] Tool call received:', data); + if (data.conversationId === conversationId) { + const toolCall = data.toolCall as { name: string; args: unknown }; + toolEventsRef.current.push({ name: toolCall.name, args: toolCall.args }); + setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.args, timestamp: Date.now() }]); + scrollToBottom(); + } + }); + + const unsubToolResult = window.electronAPI?.chat.onToolResult((data) => { + console.log('[ChatPanel] Tool result received:', data); + if (data.conversationId === conversationId) { + const result = data.result as { name: string; result: unknown }; + setToolEvents(prev => [...prev, { type: 'result', name: result.name, timestamp: Date.now() }]); + scrollToBottom(); + } + }); + const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { if (data.conversationId === conversationId) { setConversation(prev => prev ? { ...prev, title: data.title } : null); @@ -80,6 +101,8 @@ export const ChatPanel: React.FC = ({ conversationId }) => { return () => { unsubDelta?.(); + unsubToolCall?.(); + unsubToolResult?.(); unsubTitle?.(); }; }, [conversationId, loadData, scrollToBottom, checkReady]); @@ -120,6 +143,8 @@ export const ChatPanel: React.FC = ({ conversationId }) => { setIsStreaming(true); streamingRef.current = ''; setStreamingContent(''); + setToolEvents([]); + toolEventsRef.current = []; // Add user message optimistically const userMessage: ChatMessage = { @@ -145,6 +170,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { conversationId, role: 'assistant', content: assistantContent, + toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined, createdAt: new Date().toISOString() }; setMessages(prev => [...prev, assistantMessage]); @@ -229,9 +255,62 @@ export const ChatPanel: React.FC = ({ conversationId }) => { } }; + const renderToolMarkers = (events: Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>) => { + if (events.length === 0) return null; + + // Group into pairs: call + result for each tool invocation + const markers: Array<{ name: string; args?: unknown; completed: boolean }> = []; + const pendingCalls = new Map(); + + for (const event of events) { + if (event.type === 'call') { + markers.push({ name: event.name, args: event.args, completed: false }); + const count = pendingCalls.get(event.name) || 0; + pendingCalls.set(event.name, count + 1); + } else if (event.type === 'result') { + // Find the last uncompleted marker for this tool + for (let i = markers.length - 1; i >= 0; i--) { + if (markers[i].name === event.name && !markers[i].completed) { + markers[i].completed = true; + break; + } + } + } + } + + return ( +
+ {markers.map((marker, i) => { + const argsPreview = marker.args + ? Object.entries(marker.args as Record) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`) + .join(', ') + : ''; + + return ( +
+ {marker.completed ? '\u2713' : '\u25CF'} + {marker.name} + {argsPreview && ({argsPreview})} +
+ ); + })} +
+ ); + }; + const renderMessage = (msg: ChatMessage) => { if (msg.role === 'system' || msg.role === 'tool') return null; + // Parse tool calls from stored message data + const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = []; + if (msg.role === 'assistant' && msg.toolCalls) { + try { + const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args?: unknown }>; + calls.forEach(c => storedToolCalls.push({ name: c.name, args: c.args, completed: true })); + } catch { /* ignore parse errors */ } + } + return (
@@ -243,6 +322,24 @@ export const ChatPanel: React.FC = ({ conversationId }) => { {msg.role === 'user' ? 'You' : 'Assistant'}
+ {storedToolCalls.length > 0 && ( +
+ {storedToolCalls.map((marker, i) => { + const argsPreview = marker.args + ? Object.entries(marker.args as Record) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`) + .join(', ') + : ''; + return ( +
+ {'\u2713'} + {marker.name} + {argsPreview && ({argsPreview})} +
+ ); + })} +
+ )}
{msg.role === 'assistant' ? ( {msg.content} @@ -340,7 +437,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { {messages.map(renderMessage)} - {isStreaming && streamingContent && ( + {isStreaming && (streamingContent || toolEvents.length > 0) && (
{'\u{1F916}'}
@@ -348,14 +445,17 @@ export const ChatPanel: React.FC = ({ conversationId }) => { Assistant {'\u25CF'}
-
- {streamingContent} -
+ {renderToolMarkers(toolEvents)} + {streamingContent && ( +
+ {streamingContent} +
+ )}
)} - {isStreaming && !streamingContent && ( + {isStreaming && !streamingContent && toolEvents.length === 0 && (
{'\u{1F916}'}