diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx
index 5a037f45cd..cbce2e7b87 100644
--- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx
+++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx
@@ -123,7 +123,7 @@ describe(' — 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');
});
@@ -145,7 +145,7 @@ describe(' — summary label', () => {
const { lastFrame } = render(
,
);
- expect(lastFrame()).toContain('Ran 1 command');
+ expect(lastFrame()).toContain('Ran ls -la');
});
});
@@ -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', () => {
@@ -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',
);
});
@@ -201,14 +201,77 @@ 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 CSI 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('strips non-CSI ANSI sequences (charset, OSC) from description', () => {
+ const tools = [
+ make({ callId: 'c1', name: 'Shell', description: '\x1b(Bls -la\x1b[0m' }),
+ ];
+ expect(buildToolSummary(tools, false)).toBe('Ran ls -la');
+ });
+
+ it('replaces embedded newlines with spaces in description', () => {
+ const tools = [
+ make({
+ callId: 'c1',
+ name: 'Shell',
+ description: 'echo hello\nworld',
+ }),
+ ];
+ expect(buildToolSummary(tools, false)).toBe('Ran echo hello world');
});
it('mixed group with count per category', () => {
@@ -217,7 +280,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', () => {
@@ -226,7 +289,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',
);
});
});
diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
index e97a700b25..9f115a837e 100644
--- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
@@ -189,14 +189,43 @@ 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;
+
+ /* eslint-disable no-control-regex */
+ // Strip all common ANSI escape sequences: OSC, charset, CSI, and single-byte ESC
+ const stripped = raw.replace(
+ /\x1b\][^\x07]*\x07|\x1b[()][A-Z0-9]|\x1b\[[0-9;]*[a-zA-Z]|\x1b./g,
+ '',
+ );
+ // Replace all C0 control characters (including \n, \r) with spaces
+ const cleaned = stripped.replace(/[\x00-\x1f\x7f]/g, ' ').trim();
+ /* eslint-enable no-control-regex */
+
+ // 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[],
@@ -204,28 +233,35 @@ export function buildToolSummary(
): string {
if (toolCalls.length === 0) return '';
- // Group by category and count
- const counts = new Map();
+ // Group by category to preserve tool references for description access
+ const toolsByCategory = new Map();
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}`);
}
}
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
index 0d702d5c4f..efe39a69c6 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
@@ -185,7 +185,7 @@ describe('', () => {
);
const frame = lastFrame() ?? '';
// CATEGORY_ORDER: search first (capitalized), then read (lowercased)
- expect(frame).toContain('Searched 1 pattern');
+ expect(frame).toContain('Searched pattern');
expect(frame).toContain('read 2 files');
expect(frame).not.toContain('MockTool');
});
@@ -204,7 +204,7 @@ describe('', () => {
);
const frame = lastFrame() ?? '';
// Collapsible → summary line
- expect(frame).toContain('Read 1 file');
+ expect(frame).toContain('Read a.ts');
// Non-collapsible → individual ToolMessage
expect(frame).toContain('MockTool[s1]');
});
@@ -226,7 +226,7 @@ describe('', () => {
// All tools render individually — no summary line
expect(frame).toContain('MockTool[r1]');
expect(frame).toContain('MockTool[e1]');
- expect(frame).not.toContain('Read 1 file');
+ expect(frame).not.toContain('Read a.ts');
});
it('forceExpandAll passes forceShowResult to Success siblings in error group', () => {
@@ -276,7 +276,7 @@ describe('', () => {
);
const frame = lastFrame() ?? '';
// Successful ReadFile → summary line
- expect(frame).toContain('Read 1 file');
+ expect(frame).toContain('Read a.ts');
// Canceled ReadFile → individual ToolMessage (partial output visible)
expect(frame).toContain('MockTool[r2]');
});
@@ -306,7 +306,7 @@ describe('', () => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Recalled 2 memories');
// Collapsible tool still summarized
- expect(frame).toContain('Read 1 file');
+ expect(frame).toContain('Read config.yaml');
// Non-collapsible tool rendered individually
expect(frame).toContain('MockTool[s1]');
});