mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
Display tool usage (#201)
This commit is contained in:
parent
e20feaa1ab
commit
03f93d6570
4 changed files with 457 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
22
apps/web/src/components/ui/ToolUsageBadge.tsx
Normal file
22
apps/web/src/components/ui/ToolUsageBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
272
apps/web/src/lib/__tests__/toolUsageParser.test.ts
Normal file
272
apps/web/src/lib/__tests__/toolUsageParser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
140
apps/web/src/lib/toolUsageParser.ts
Normal file
140
apps/web/src/lib/toolUsageParser.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue