From 03f93d6570f43f965409e2f8441deccbdc982363 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 4 Jul 2025 08:35:33 -0400 Subject: [PATCH] Display tool usage (#201) --- .../app/(authenticated)/usage/Messages.tsx | 26 +- apps/web/src/components/ui/ToolUsageBadge.tsx | 22 ++ .../src/lib/__tests__/toolUsageParser.test.ts | 272 ++++++++++++++++++ apps/web/src/lib/toolUsageParser.ts | 140 +++++++++ 4 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/ui/ToolUsageBadge.tsx create mode 100644 apps/web/src/lib/__tests__/toolUsageParser.test.ts create mode 100644 apps/web/src/lib/toolUsageParser.ts diff --git a/apps/web/src/app/(authenticated)/usage/Messages.tsx b/apps/web/src/app/(authenticated)/usage/Messages.tsx index 715a9bf15b..fcbd9f097e 100644 --- a/apps/web/src/app/(authenticated)/usage/Messages.tsx +++ b/apps/web/src/app/(authenticated)/usage/Messages.tsx @@ -10,6 +10,8 @@ import { cn } from '@/lib/utils'; import { formatTimestamp } from '@/lib/formatters'; import { useAutoScroll } from '@/hooks/useAutoScroll'; import { CodeBlock } from '@/components/ui/CodeBlock'; +import { ToolUsageBadge } from '@/components/ui/ToolUsageBadge'; +import { parseToolUsage } from '@/lib/toolUsageParser'; // Custom component to render links as plain text to avoid broken/nonsensical links const PlainTextLink = ({ children }: { children?: React.ReactNode }) => { @@ -51,6 +53,7 @@ type DecoratedMessage = Omit & { name: string; timestamp: string; showHeader?: boolean; + toolUsage?: ReturnType; }; // Determine if a message should show its header based on grouping rules @@ -210,6 +213,7 @@ export const Messages = ({ message.type === 'ask' && message.ask === 'followup'; const isCommand = message.type === 'ask' && message.ask === 'command'; + const isTool = message.type === 'ask' && message.ask === 'tool'; const questionData = isQuestion && message.text ? parseQuestionData(message.text) @@ -217,6 +221,17 @@ export const Messages = ({ const messageId = `message-${message.id}`; + // For tool messages, render only the tool usage badge in the space between bubbles + if (isTool) { + return ( +
+ {message.toolUsage && ( + + )} +
+ ); + } + return (
{ - // Always show followup and command messages regardless of text content + // Always show followup, command, and tool messages regardless of text content if ( message.type === 'ask' && - (message.ask === 'followup' || message.ask === 'command') + (message.ask === 'followup' || + message.ask === 'command' || + message.ask === 'tool') ) { return true; } diff --git a/apps/web/src/components/ui/ToolUsageBadge.tsx b/apps/web/src/components/ui/ToolUsageBadge.tsx new file mode 100644 index 0000000000..9c9131a4cd --- /dev/null +++ b/apps/web/src/components/ui/ToolUsageBadge.tsx @@ -0,0 +1,22 @@ +import { cn } from '@/lib/utils'; +import type { ToolUsage } from '@/lib/toolUsageParser'; +import { formatToolUsage } from '@/lib/toolUsageParser'; + +type ToolUsageBadgeProps = { + usage: ToolUsage; + className?: string; +}; + +export const ToolUsageBadge = ({ usage, className }: ToolUsageBadgeProps) => { + return ( +
+ {formatToolUsage(usage)} +
+ ); +}; diff --git a/apps/web/src/lib/__tests__/toolUsageParser.test.ts b/apps/web/src/lib/__tests__/toolUsageParser.test.ts new file mode 100644 index 0000000000..87de0e78f3 --- /dev/null +++ b/apps/web/src/lib/__tests__/toolUsageParser.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { + parseToolUsage, + extractToolUsageFromAsk, + formatToolUsage, +} from '../toolUsageParser'; + +describe('toolUsageParser', () => { + describe('extractToolUsageFromAsk', () => { + it('should extract tool usage from ask=tool messages', () => { + const message = { + ask: 'tool', + text: '{"tool":"editedExistingFile","path":"src/components/Button.tsx","isOutsideWorkspace":false,"isProtected":false,"diff":"@@ -1,3 +1,5 @@\\n import React from \'react\';\\n+import { cn } from \'@/lib/utils\';\\n \\n export const Button = () => {\\n+ return ;\\n };"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Edited', + details: 'src/components/Button.tsx', + }); + }); + + it('should handle createdNewFile tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"createdNewFile","path":"src/newfile.ts"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Created', + details: 'src/newfile.ts', + }); + }); + + it('should handle readFile tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"readFile","path":"src/app.ts"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Read', + details: 'src/app.ts', + }); + }); + + it('should handle readFile tool with single batchFile', () => { + const message = { + ask: 'tool', + text: '{"tool":"readFile","batchFiles":[{"path":"src/app.ts","lineSnippet":"","isOutsideWorkspace":false,"key":"src/app.ts","content":"/path/to/src/app.ts"}]}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Read', + details: 'src/app.ts', + }); + }); + + it('should handle readFile tool with multiple batchFiles', () => { + const message = { + ask: 'tool', + text: '{"tool":"readFile","batchFiles":[{"path":"turbo.json","lineSnippet":"","isOutsideWorkspace":false,"key":"turbo.json","content":"/path/to/turbo.json"},{"path":"tsconfig.json","lineSnippet":"","isOutsideWorkspace":false,"key":"tsconfig.json","content":"/path/to/tsconfig.json"},{"path":"CHANGELOG.md","lineSnippet":"","isOutsideWorkspace":false,"key":"CHANGELOG.md","content":"/path/to/CHANGELOG.md"}]}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Read', + details: '3 files (turbo.json, ...)', + }); + }); + + it('should handle newFileCreated tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"newFileCreated","path":"test-file-1.js","content":"// Test File 1\\nfunction greet(name) {\\n return \\"Hello \\" + name;\\n}\\n\\nmodule.exports = { greet };","isOutsideWorkspace":false,"isProtected":false}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Created', + details: 'test-file-1.js', + }); + }); + + it('should handle appliedDiff tool with single file', () => { + const message = { + ask: 'tool', + text: '{"tool":"appliedDiff","path":"single-file.js","isProtected":false}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Edited', + details: 'single-file.js', + }); + }); + + it('should handle appliedDiff tool with multiple batchDiffs', () => { + const message = { + ask: 'tool', + text: '{"tool":"appliedDiff","batchDiffs":[{"path":"test-file-1.js","changeCount":1,"key":"test-file-1.js (1 change)"},{"path":"test-file-2.js","changeCount":1,"key":"test-file-2.js (1 change)"},{"path":"test-file-3.js","changeCount":1,"key":"test-file-3.js (1 change)"}],"isProtected":false}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Edited', + details: '3 files (test-file-1.js, ...)', + }); + }); + + it('should handle listFilesTopLevel tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"listFilesTopLevel","path":"Roo-Code-3","isOutsideWorkspace":false,"content":"CHANGELOG.md\\nCODE_OF_CONDUCT.md\\nCONTRIBUTING.md\\npackage.json\\napps/\\npackages/"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Listed', + details: 'Roo-Code-3', + }); + }); + + it('should handle listFilesRecursive tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"listFilesRecursive","path":"src/core/tools","isOutsideWorkspace":false,"content":"accessMcpResourceTool.ts\\napplyDiffTool.ts\\naskFollowupQuestionTool.ts\\n__tests__/"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Listed', + details: 'src/core/tools', + }); + }); + + it('should handle codebaseSearch tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"codebaseSearch","query":"authentication"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Searched', + details: '"authentication"', + }); + }); + + it('should handle codebaseSearch tool without query', () => { + const message = { + ask: 'tool', + text: '{"tool":"codebaseSearch"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Searched', + }); + }); + + it('should handle searchFiles tool', () => { + const message = { + ask: 'tool', + text: '{"tool":"searchFiles","regex":"function.*test"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Grepped', + details: 'function.*test', + }); + }); + + it('should handle searchFiles tool with query fallback', () => { + const message = { + ask: 'tool', + text: '{"tool":"searchFiles","query":"test pattern"}', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toEqual({ + action: 'Grepped', + details: 'test pattern', + }); + }); + + it('should return null for non-tool messages', () => { + const message = { + ask: 'text', + text: 'Some regular text', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toBeNull(); + }); + + it('should handle invalid JSON gracefully', () => { + const message = { + ask: 'tool', + text: 'invalid json', + }; + + const result = extractToolUsageFromAsk(message); + + expect(result).toBeNull(); + }); + }); + + describe('parseToolUsage', () => { + it('should return null for non-tool messages', () => { + const message = { + text: 'Some regular text', + say: 'api_req_started', + ask: null, + }; + + const result = parseToolUsage(message); + + expect(result).toBeNull(); + }); + + it('should parse ask=tool messages', () => { + const message = { + text: '{"tool":"readFile","path":"src/app.ts"}', + say: 'api_req_started', + ask: 'tool', + }; + + const result = parseToolUsage(message); + + expect(result).toEqual({ action: 'Read', details: 'src/app.ts' }); + }); + + it('should handle empty message', () => { + const message = { text: null, say: null }; + const result = parseToolUsage(message); + + expect(result).toBeNull(); + }); + }); + + describe('formatToolUsage', () => { + it('should format tool usage with details', () => { + const usage = { action: 'Read', details: 'Messages.tsx' as const }; + expect(formatToolUsage(usage)).toBe('Read Messages.tsx'); + }); + + it('should format tool usage without details', () => { + const usage = { action: 'Searched' as const }; + expect(formatToolUsage(usage)).toBe('Searched'); + }); + }); +}); diff --git a/apps/web/src/lib/toolUsageParser.ts b/apps/web/src/lib/toolUsageParser.ts new file mode 100644 index 0000000000..9811851955 --- /dev/null +++ b/apps/web/src/lib/toolUsageParser.ts @@ -0,0 +1,140 @@ +/** + * Tool usage parser for extracting and formatting tool usage from messages + */ + +export type ToolUsage = { + action: string; + details?: string; +}; + +/** + * Extract tool usage from ask=tool messages + */ +export function extractToolUsageFromAsk(message: { + ask?: string | null; + text?: string | null; +}): ToolUsage | null { + if (message.ask !== 'tool' || !message.text) return null; + + try { + const toolData = JSON.parse(message.text); + const tool = toolData.tool; + const path = toolData.path; + const query = toolData.query; + const regex = toolData.regex; + + switch (tool) { + case 'codebaseSearch': + return { + action: 'Searched', + details: query ? `"${query}"` : undefined, + }; + case 'editedExistingFile': + return { + action: 'Edited', + details: path || undefined, + }; + case 'createdNewFile': + return { + action: 'Created', + details: path || undefined, + }; + case 'readFile': + // Handle batch files (multiple reads) + if (toolData.batchFiles && Array.isArray(toolData.batchFiles)) { + const fileCount = toolData.batchFiles.length; + const firstFile = toolData.batchFiles[0]?.path || 'file'; + if (fileCount === 1) { + return { + action: 'Read', + details: firstFile, + }; + } else { + return { + action: 'Read', + details: `${fileCount} files (${firstFile}, ...)`, + }; + } + } + // Handle single file read + return { + action: 'Read', + details: path || undefined, + }; + case 'searchFiles': + return { + action: 'Grepped', + details: regex || query || 'pattern', + }; + case 'listFiles': + return { + action: 'Listed', + details: path || undefined, + }; + case 'listFilesTopLevel': + return { + action: 'Listed', + details: path || undefined, + }; + case 'listFilesRecursive': + return { + action: 'Listed', + details: path || undefined, + }; + case 'newFileCreated': + return { + action: 'Created', + details: path || undefined, + }; + case 'appliedDiff': + // Handle batch diffs (multiple file edits) + if (toolData.batchDiffs && Array.isArray(toolData.batchDiffs)) { + const fileCount = toolData.batchDiffs.length; + const firstFile = toolData.batchDiffs[0]?.path || 'file'; + if (fileCount === 1) { + return { + action: 'Edited', + details: firstFile, + }; + } else { + return { + action: 'Edited', + details: `${fileCount} files (${firstFile}, ...)`, + }; + } + } + // Handle single file diff + return { + action: 'Edited', + details: path || undefined, + }; + default: + return null; + } + } catch { + return null; + } +} + +/** + * Parse tool usage from a message + * Since there can only be one tool usage per message, this returns a single ToolUsage or null + */ +export function parseToolUsage(message: { + text?: string | null; + say?: string | null; + ask?: string | null; +}): ToolUsage | null { + // Extract from ask=tool messages + return extractToolUsageFromAsk(message); +} + +/** + * Format tool usage for display + */ +export function formatToolUsage(usage: ToolUsage): string { + if (usage.details) { + return `${usage.action} ${usage.details}`; + } + return usage.action; +}