Skip to content

Commit 03f93d6

Browse files
authored
Display tool usage (#201)
1 parent e20feaa commit 03f93d6

File tree

4 files changed

+457
-3
lines changed

4 files changed

+457
-3
lines changed

apps/web/src/app/(authenticated)/usage/Messages.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { cn } from '@/lib/utils';
1010
import { formatTimestamp } from '@/lib/formatters';
1111
import { useAutoScroll } from '@/hooks/useAutoScroll';
1212
import { CodeBlock } from '@/components/ui/CodeBlock';
13+
import { ToolUsageBadge } from '@/components/ui/ToolUsageBadge';
14+
import { parseToolUsage } from '@/lib/toolUsageParser';
1315

1416
// Custom component to render links as plain text to avoid broken/nonsensical links
1517
const PlainTextLink = ({ children }: { children?: React.ReactNode }) => {
@@ -51,6 +53,7 @@ type DecoratedMessage = Omit<Message, 'timestamp'> & {
5153
name: string;
5254
timestamp: string;
5355
showHeader?: boolean;
56+
toolUsage?: ReturnType<typeof parseToolUsage>;
5457
};
5558

5659
// Determine if a message should show its header based on grouping rules
@@ -210,13 +213,25 @@ export const Messages = ({
210213
message.type === 'ask' && message.ask === 'followup';
211214
const isCommand =
212215
message.type === 'ask' && message.ask === 'command';
216+
const isTool = message.type === 'ask' && message.ask === 'tool';
213217
const questionData =
214218
isQuestion && message.text
215219
? parseQuestionData(message.text)
216220
: null;
217221

218222
const messageId = `message-${message.id}`;
219223

224+
// For tool messages, render only the tool usage badge in the space between bubbles
225+
if (isTool) {
226+
return (
227+
<div key={message.id} className="py-2 pl-4">
228+
{message.toolUsage && (
229+
<ToolUsageBadge usage={message.toolUsage} />
230+
)}
231+
</div>
232+
);
233+
}
234+
220235
return (
221236
<div
222237
key={message.id}
@@ -387,14 +402,19 @@ const decorate = ({
387402
const name = role === 'user' ? 'User' : 'Roo Code';
388403
const timestamp = formatTimestamp(message.timestamp);
389404

390-
return { ...message, role, name, timestamp };
405+
// Parse tool usage for assistant messages
406+
const toolUsage = role === 'assistant' ? parseToolUsage(message) : null;
407+
408+
return { ...message, role, name, timestamp, toolUsage };
391409
};
392410

393411
const isVisible = (message: Message) => {
394-
// Always show followup and command messages regardless of text content
412+
// Always show followup, command, and tool messages regardless of text content
395413
if (
396414
message.type === 'ask' &&
397-
(message.ask === 'followup' || message.ask === 'command')
415+
(message.ask === 'followup' ||
416+
message.ask === 'command' ||
417+
message.ask === 'tool')
398418
) {
399419
return true;
400420
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { cn } from '@/lib/utils';
2+
import type { ToolUsage } from '@/lib/toolUsageParser';
3+
import { formatToolUsage } from '@/lib/toolUsageParser';
4+
5+
type ToolUsageBadgeProps = {
6+
usage: ToolUsage;
7+
className?: string;
8+
};
9+
10+
export const ToolUsageBadge = ({ usage, className }: ToolUsageBadgeProps) => {
11+
return (
12+
<div
13+
className={cn(
14+
'block text-xs leading-5',
15+
'text-gray-400/80 dark:text-gray-500/70',
16+
className,
17+
)}
18+
>
19+
{formatToolUsage(usage)}
20+
</div>
21+
);
22+
};
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
parseToolUsage,
4+
extractToolUsageFromAsk,
5+
formatToolUsage,
6+
} from '../toolUsageParser';
7+
8+
describe('toolUsageParser', () => {
9+
describe('extractToolUsageFromAsk', () => {
10+
it('should extract tool usage from ask=tool messages', () => {
11+
const message = {
12+
ask: 'tool',
13+
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 };"}',
14+
};
15+
16+
const result = extractToolUsageFromAsk(message);
17+
18+
expect(result).toEqual({
19+
action: 'Edited',
20+
details: 'src/components/Button.tsx',
21+
});
22+
});
23+
24+
it('should handle createdNewFile tool', () => {
25+
const message = {
26+
ask: 'tool',
27+
text: '{"tool":"createdNewFile","path":"src/newfile.ts"}',
28+
};
29+
30+
const result = extractToolUsageFromAsk(message);
31+
32+
expect(result).toEqual({
33+
action: 'Created',
34+
details: 'src/newfile.ts',
35+
});
36+
});
37+
38+
it('should handle readFile tool', () => {
39+
const message = {
40+
ask: 'tool',
41+
text: '{"tool":"readFile","path":"src/app.ts"}',
42+
};
43+
44+
const result = extractToolUsageFromAsk(message);
45+
46+
expect(result).toEqual({
47+
action: 'Read',
48+
details: 'src/app.ts',
49+
});
50+
});
51+
52+
it('should handle readFile tool with single batchFile', () => {
53+
const message = {
54+
ask: 'tool',
55+
text: '{"tool":"readFile","batchFiles":[{"path":"src/app.ts","lineSnippet":"","isOutsideWorkspace":false,"key":"src/app.ts","content":"/path/to/src/app.ts"}]}',
56+
};
57+
58+
const result = extractToolUsageFromAsk(message);
59+
60+
expect(result).toEqual({
61+
action: 'Read',
62+
details: 'src/app.ts',
63+
});
64+
});
65+
66+
it('should handle readFile tool with multiple batchFiles', () => {
67+
const message = {
68+
ask: 'tool',
69+
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"}]}',
70+
};
71+
72+
const result = extractToolUsageFromAsk(message);
73+
74+
expect(result).toEqual({
75+
action: 'Read',
76+
details: '3 files (turbo.json, ...)',
77+
});
78+
});
79+
80+
it('should handle newFileCreated tool', () => {
81+
const message = {
82+
ask: 'tool',
83+
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}',
84+
};
85+
86+
const result = extractToolUsageFromAsk(message);
87+
88+
expect(result).toEqual({
89+
action: 'Created',
90+
details: 'test-file-1.js',
91+
});
92+
});
93+
94+
it('should handle appliedDiff tool with single file', () => {
95+
const message = {
96+
ask: 'tool',
97+
text: '{"tool":"appliedDiff","path":"single-file.js","isProtected":false}',
98+
};
99+
100+
const result = extractToolUsageFromAsk(message);
101+
102+
expect(result).toEqual({
103+
action: 'Edited',
104+
details: 'single-file.js',
105+
});
106+
});
107+
108+
it('should handle appliedDiff tool with multiple batchDiffs', () => {
109+
const message = {
110+
ask: 'tool',
111+
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}',
112+
};
113+
114+
const result = extractToolUsageFromAsk(message);
115+
116+
expect(result).toEqual({
117+
action: 'Edited',
118+
details: '3 files (test-file-1.js, ...)',
119+
});
120+
});
121+
122+
it('should handle listFilesTopLevel tool', () => {
123+
const message = {
124+
ask: 'tool',
125+
text: '{"tool":"listFilesTopLevel","path":"Roo-Code-3","isOutsideWorkspace":false,"content":"CHANGELOG.md\\nCODE_OF_CONDUCT.md\\nCONTRIBUTING.md\\npackage.json\\napps/\\npackages/"}',
126+
};
127+
128+
const result = extractToolUsageFromAsk(message);
129+
130+
expect(result).toEqual({
131+
action: 'Listed',
132+
details: 'Roo-Code-3',
133+
});
134+
});
135+
136+
it('should handle listFilesRecursive tool', () => {
137+
const message = {
138+
ask: 'tool',
139+
text: '{"tool":"listFilesRecursive","path":"src/core/tools","isOutsideWorkspace":false,"content":"accessMcpResourceTool.ts\\napplyDiffTool.ts\\naskFollowupQuestionTool.ts\\n__tests__/"}',
140+
};
141+
142+
const result = extractToolUsageFromAsk(message);
143+
144+
expect(result).toEqual({
145+
action: 'Listed',
146+
details: 'src/core/tools',
147+
});
148+
});
149+
150+
it('should handle codebaseSearch tool', () => {
151+
const message = {
152+
ask: 'tool',
153+
text: '{"tool":"codebaseSearch","query":"authentication"}',
154+
};
155+
156+
const result = extractToolUsageFromAsk(message);
157+
158+
expect(result).toEqual({
159+
action: 'Searched',
160+
details: '"authentication"',
161+
});
162+
});
163+
164+
it('should handle codebaseSearch tool without query', () => {
165+
const message = {
166+
ask: 'tool',
167+
text: '{"tool":"codebaseSearch"}',
168+
};
169+
170+
const result = extractToolUsageFromAsk(message);
171+
172+
expect(result).toEqual({
173+
action: 'Searched',
174+
});
175+
});
176+
177+
it('should handle searchFiles tool', () => {
178+
const message = {
179+
ask: 'tool',
180+
text: '{"tool":"searchFiles","regex":"function.*test"}',
181+
};
182+
183+
const result = extractToolUsageFromAsk(message);
184+
185+
expect(result).toEqual({
186+
action: 'Grepped',
187+
details: 'function.*test',
188+
});
189+
});
190+
191+
it('should handle searchFiles tool with query fallback', () => {
192+
const message = {
193+
ask: 'tool',
194+
text: '{"tool":"searchFiles","query":"test pattern"}',
195+
};
196+
197+
const result = extractToolUsageFromAsk(message);
198+
199+
expect(result).toEqual({
200+
action: 'Grepped',
201+
details: 'test pattern',
202+
});
203+
});
204+
205+
it('should return null for non-tool messages', () => {
206+
const message = {
207+
ask: 'text',
208+
text: 'Some regular text',
209+
};
210+
211+
const result = extractToolUsageFromAsk(message);
212+
213+
expect(result).toBeNull();
214+
});
215+
216+
it('should handle invalid JSON gracefully', () => {
217+
const message = {
218+
ask: 'tool',
219+
text: 'invalid json',
220+
};
221+
222+
const result = extractToolUsageFromAsk(message);
223+
224+
expect(result).toBeNull();
225+
});
226+
});
227+
228+
describe('parseToolUsage', () => {
229+
it('should return null for non-tool messages', () => {
230+
const message = {
231+
text: 'Some regular text',
232+
say: 'api_req_started',
233+
ask: null,
234+
};
235+
236+
const result = parseToolUsage(message);
237+
238+
expect(result).toBeNull();
239+
});
240+
241+
it('should parse ask=tool messages', () => {
242+
const message = {
243+
text: '{"tool":"readFile","path":"src/app.ts"}',
244+
say: 'api_req_started',
245+
ask: 'tool',
246+
};
247+
248+
const result = parseToolUsage(message);
249+
250+
expect(result).toEqual({ action: 'Read', details: 'src/app.ts' });
251+
});
252+
253+
it('should handle empty message', () => {
254+
const message = { text: null, say: null };
255+
const result = parseToolUsage(message);
256+
257+
expect(result).toBeNull();
258+
});
259+
});
260+
261+
describe('formatToolUsage', () => {
262+
it('should format tool usage with details', () => {
263+
const usage = { action: 'Read', details: 'Messages.tsx' as const };
264+
expect(formatToolUsage(usage)).toBe('Read Messages.tsx');
265+
});
266+
267+
it('should format tool usage without details', () => {
268+
const usage = { action: 'Searched' as const };
269+
expect(formatToolUsage(usage)).toBe('Searched');
270+
});
271+
});
272+
});

0 commit comments

Comments
 (0)