Display tool usage (#201)

This commit is contained in:
Matt Rubens 2025-07-04 08:35:33 -04:00 committed by GitHub
parent e20feaa1ab
commit 03f93d6570
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 457 additions and 3 deletions

View file

@ -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<Message, 'timestamp'> & {
name: string;
timestamp: string;
showHeader?: boolean;
toolUsage?: ReturnType<typeof parseToolUsage>;
};
// 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 (
<div key={message.id} className="py-2 pl-4">
{message.toolUsage && (
<ToolUsageBadge usage={message.toolUsage} />
)}
</div>
);
}
return (
<div
key={message.id}
@ -387,14 +402,19 @@ const decorate = ({
const name = role === 'user' ? 'User' : 'Roo Code';
const timestamp = formatTimestamp(message.timestamp);
return { ...message, role, name, timestamp };
// Parse tool usage for assistant messages
const toolUsage = role === 'assistant' ? parseToolUsage(message) : null;
return { ...message, role, name, timestamp, toolUsage };
};
const isVisible = (message: Message) => {
// 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;
}

View file

@ -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 (
<div
className={cn(
'block text-xs leading-5',
'text-gray-400/80 dark:text-gray-500/70',
className,
)}
>
{formatToolUsage(usage)}
</div>
);
};

View file

@ -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 <button className={cn(\'btn\')}>Click me</button>;\\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');
});
});
});

View file

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