feat: show tool usage in the ai chat

This commit is contained in:
2026-02-12 10:21:48 +01:00
parent 769980360e
commit 226850e446
3 changed files with 177 additions and 10 deletions

View File

@@ -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<Record<string, unknown>> = [
{ 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 };
}
/**

View File

@@ -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; }
}

View File

@@ -13,6 +13,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [inputValue, setInputValue] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [needsApiKey, setNeedsApiKey] = useState(false);
@@ -22,6 +23,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamingRef = useRef('');
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
// Scroll to bottom when messages change
const scrollToBottom = useCallback(() => {
@@ -72,6 +74,25 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ 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<ChatPanelProps> = ({ conversationId }) => {
return () => {
unsubDelta?.();
unsubToolCall?.();
unsubToolResult?.();
unsubTitle?.();
};
}, [conversationId, loadData, scrollToBottom, checkReady]);
@@ -120,6 +143,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ 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<ChatPanelProps> = ({ 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<ChatPanelProps> = ({ 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<string, number>();
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 (
<div className="tool-markers">
{markers.map((marker, i) => {
const argsPreview = marker.args
? Object.entries(marker.args as Record<string, unknown>)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
.join(', ')
: '';
return (
<div key={i} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
<span className="tool-marker-name">{marker.name}</span>
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
</div>
);
})}
</div>
);
};
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 (
<div key={msg.id} className={`chat-message ${msg.role}`}>
<div className="chat-message-avatar">
@@ -243,6 +322,24 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{msg.role === 'user' ? 'You' : 'Assistant'}
</span>
</div>
{storedToolCalls.length > 0 && (
<div className="tool-markers">
{storedToolCalls.map((marker, i) => {
const argsPreview = marker.args
? Object.entries(marker.args as Record<string, unknown>)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
.join(', ')
: '';
return (
<div key={i} className="tool-marker completed">
<span className="tool-marker-icon">{'\u2713'}</span>
<span className="tool-marker-name">{marker.name}</span>
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
</div>
);
})}
</div>
)}
<div className="chat-message-text">
{msg.role === 'assistant' ? (
<Markdown gfm>{msg.content}</Markdown>
@@ -340,7 +437,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{messages.map(renderMessage)}
{isStreaming && streamingContent && (
{isStreaming && (streamingContent || toolEvents.length > 0) && (
<div className="chat-message assistant streaming">
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">
@@ -348,14 +445,17 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<span className="chat-message-role">Assistant</span>
<span className="streaming-indicator">{'\u25CF'}</span>
</div>
{renderToolMarkers(toolEvents)}
{streamingContent && (
<div className="chat-message-text">
<Markdown gfm>{streamingContent}</Markdown>
</div>
)}
</div>
</div>
)}
{isStreaming && !streamingContent && (
{isStreaming && !streamingContent && toolEvents.length === 0 && (
<div className="chat-message assistant thinking">
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">