Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('<CompactToolGroupDisplay /> — summary label', () => {
);
const frame = lastFrame()!;
// CATEGORY_ORDER: search → read → list → ...
expect(frame).toContain('Searched 1 pattern');
expect(frame).toContain('Searched search pattern');
expect(frame).toContain('read 2 files');
});

Expand All @@ -145,7 +145,7 @@ describe('<CompactToolGroupDisplay /> — summary label', () => {
const { lastFrame } = render(
<CompactToolGroupDisplay toolCalls={tools} contentWidth={80} />,
);
expect(lastFrame()).toContain('Ran 1 command');
expect(lastFrame()).toContain('Ran ls -la');
});
});

Expand All @@ -166,12 +166,12 @@ describe('buildToolSummary', () => {
expect(buildToolSummary([], false)).toBe('');
});

it('single tool uses count format', () => {
expect(buildToolSummary([make({})], false)).toBe('Read 1 file');
it('single tool uses description format', () => {
expect(buildToolSummary([make({})], false)).toBe('Read a.ts');
});

it('single tool uses progressive verb when active', () => {
expect(buildToolSummary([make({})], true)).toBe('Reading 1 file');
expect(buildToolSummary([make({})], true)).toBe('Reading a.ts');
});

it('multiple same-type tools use count', () => {
Expand All @@ -191,7 +191,7 @@ describe('buildToolSummary', () => {
];
// CATEGORY_ORDER: search → read → list → command → edit
expect(buildToolSummary(tools, false)).toBe(
'Read 1 file, ran 1 command, edited 1 file',
'Read a.ts, ran npm test, edited b.ts',
);
});

Expand All @@ -201,14 +201,59 @@ describe('buildToolSummary', () => {
make({ callId: 'c2', name: 'Shell', description: 'ls' }),
];
const result = buildToolSummary(tools, false);
expect(result).toBe('Read 1 file, ran 1 command');
expect(result).toBe('Read a.ts, ran ls');
});

it('unknown tool names fall to other category', () => {
const tools = [
make({ callId: 'c1', name: 'UnknownTool', description: 'something' }),
];
expect(buildToolSummary(tools, false)).toBe('Used 1 tool');
expect(buildToolSummary(tools, false)).toBe('Used something');
});

it('falls back to count format when description is empty', () => {
const tools = [make({ callId: 'c1', name: 'ReadFile', description: '' })];
expect(buildToolSummary(tools, false)).toBe('Read 1 file');
});

it('falls back to count format when description is undefined', () => {
const tools = [
make({
callId: 'c1',
name: 'ReadFile',
description: undefined as unknown as string,
}),
];
expect(buildToolSummary(tools, false)).toBe('Read 1 file');
});

it('falls back to count format when description is JSON (error args)', () => {
const tools = [
make({
callId: 'c1',
name: 'ReadFile',
description: '{"file_path":"/tmp/test.txt"}',
}),
];
expect(buildToolSummary(tools, false)).toBe('Read 1 file');
});

it('falls back to count format when description starts with array bracket', () => {
const tools = [
make({ callId: 'c1', name: 'Shell', description: '["ls", "-la"]' }),
];
expect(buildToolSummary(tools, false)).toBe('Ran 1 command');
});

it('strips ANSI escape sequences from description', () => {
const tools = [
make({
callId: 'c1',
name: 'ReadFile',
description: '\x1b[32ma.ts\x1b[0m',
}),
];
expect(buildToolSummary(tools, false)).toBe('Read a.ts');
});

it('mixed group with count per category', () => {
Expand All @@ -217,7 +262,7 @@ describe('buildToolSummary', () => {
make({ callId: 'c2', name: 'ReadFile', description: 'b.ts' }),
make({ callId: 'c3', name: 'Shell', description: 'npm test' }),
];
expect(buildToolSummary(tools, false)).toBe('Read 2 files, ran 1 command');
expect(buildToolSummary(tools, false)).toBe('Read 2 files, ran npm test');
});

it('legacy display names map to correct categories', () => {
Expand All @@ -226,7 +271,7 @@ describe('buildToolSummary', () => {
make({ callId: 'c2', name: 'ReadFolder', description: '/src' }),
];
expect(buildToolSummary(tools, false)).toBe(
'Searched 1 pattern, listed 1 directory',
'Searched pattern, listed /src',
);
});
});
Expand Down
55 changes: 44 additions & 11 deletions packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,43 +189,76 @@ export function isCollapsibleTool(toolName: string): boolean {
return COLLAPSIBLE_CATEGORIES.has(getToolCategory(toolName));
}

/**
* Strip ANSI control sequences and reject JSON-looking error fallbacks.
*
* When a tool call errors, `useReactToolScheduler` sets description to
* `JSON.stringify(request.args)` which produces `{...}` blobs. Return
* `undefined` for those so the caller falls back to count format.
*/
function safeDescription(raw: string | undefined): string | undefined {
if (!raw) return undefined;

// Strip ANSI escape sequences
// eslint-disable-next-line no-control-regex
const stripped = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
// Reject control characters (except tab/newline)
// eslint-disable-next-line no-control-regex
const cleaned = stripped.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');

// Reject JSON-looking blobs (error fallback from args)
if (cleaned.startsWith('{') || cleaned.startsWith('[')) return undefined;

return cleaned || undefined;
}

/**
* Build a semantic summary line from a batch of tool calls.
*
* Single tool → "Read 1 file" / "Ran 1 command"
* Multi same → "Read 3 files"
* Multi mixed → "Read 3 files, edited 2 files, ran 1 command"
* Single tool (with description) → "Read a.ts" / "Ran ls -la"
* Single tool (no description) → "Read 1 file" / "Ran 1 command"
* Multi same → "Read 3 files"
* Multi mixed → "Read a.ts, ran npm test, edited b.ts"
*
* Uses past tense when all tools are done, present progressive when active.
* Falls back to count format when description is empty, contains control
* characters, or looks like a JSON blob (e.g. error fallback from args).
*/
export function buildToolSummary(
toolCalls: IndividualToolCallDisplay[],
isActive: boolean,
): string {
if (toolCalls.length === 0) return '';

// Group by category and count
const counts = new Map<ToolCategory, number>();
// Group by category to preserve tool references for description access
const toolsByCategory = new Map<ToolCategory, IndividualToolCallDisplay[]>();

for (const tool of toolCalls) {
const cat = getToolCategory(tool.name);
counts.set(cat, (counts.get(cat) ?? 0) + 1);
const arr = toolsByCategory.get(cat) ?? [];
arr.push(tool);
toolsByCategory.set(cat, arr);
}

const parts: string[] = [];
for (const cat of CATEGORY_ORDER) {
const count = counts.get(cat);
if (!count) continue;
const tools = toolsByCategory.get(cat);
if (!tools || tools.length === 0) continue;

const template = CATEGORY_TEMPLATES[cat];
const verb = isActive ? template.activeVerb : template.pastVerb;
const lower = parts.length > 0;
const v = lower ? verb.toLowerCase() : verb;

if (count === 1) {
parts.push(`${v} 1 ${template.singular}`);
if (tools.length === 1) {
const safeDesc = safeDescription(tools[0].description);
if (safeDesc !== undefined) {
parts.push(`${v} ${safeDesc}`);
} else {
parts.push(`${v} 1 ${template.singular}`);
}
} else {
parts.push(`${v} ${count} ${template.plural}`);
parts.push(`${v} ${tools.length} ${template.plural}`);
}
}

Expand Down
Loading