diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx
index f2dca327043..ed454a6bbfe 100644
--- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx
+++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx
@@ -125,7 +125,7 @@ describe(' — summary label', () => {
const frame = lastFrame()!;
// CATEGORY_ORDER: search → read → list → ...
expect(frame).toContain('Searched search pattern');
- expect(frame).toContain('read 2 files');
+ expect(frame).toContain('read a.ts, b.ts');
});
it('renders nothing for empty tool calls', () => {
@@ -163,7 +163,7 @@ describe(' — summary label', () => {
expect(frame.replace(/\s/g, '')).toContain(`Read${description}`);
});
- it('shows the latest executing description while a batch is active', () => {
+ it('shows all descriptions inline when a batch is active with ≤ 3 tools', () => {
const tools = [
toolCall({
callId: 'c1',
@@ -188,9 +188,9 @@ describe(' — summary label', () => {
);
const frame = lastFrame()!;
- expect(frame).toContain('Reading 3 files…');
- expect(frame).toContain('⎿ current.ts');
- expect(frame).not.toContain('queued.ts');
+ expect(frame).toContain('Reading completed.ts, current.ts, queued.ts…');
+ // No redundant hint line when descriptions are already inline.
+ expect(frame).not.toContain('⎿');
});
it('hides the description hint when a batch completes', () => {
@@ -203,7 +203,7 @@ describe(' — summary label', () => {
);
const frame = lastFrame()!;
- expect(frame).toContain('Read 2 files');
+ expect(frame).toContain('Read a.ts, b.ts');
expect(frame).not.toContain('⎿');
});
@@ -268,13 +268,14 @@ describe(' — summary label', () => {
);
const frame = lastFrame()!;
- expect(frame.split('\n')).toHaveLength(2);
- expect(frame).toContain('Reading 30 files…');
+ // Summary line wraps + hint line → at least 2 rows at 80 columns.
+ expect(frame.split('\n').length).toBeGreaterThanOrEqual(2);
+ expect(frame).toContain('... and 28 more');
expect(frame).toContain('⎿ packages/cli/src/ui/components/example-30.tsx');
- expect(frame).not.toContain('example-01.tsx');
+ expect(frame).not.toContain('example-03.tsx');
});
- it('truncates a long active hint to one row', () => {
+ it('wraps a long inline summary without a redundant hint', () => {
const currentPath =
'packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx';
const tools = [
@@ -290,12 +291,12 @@ describe(' — summary label', () => {
,
);
const frame = lastFrame()!;
- const lines = frame.split('\n');
- expect(lines).toHaveLength(2);
- expect(lines[1]).toContain('⎿ packages/cli');
- expect(lines[1]).toMatch(/…$/);
- expect(frame).not.toContain(currentPath);
+ // Summary wraps across multiple lines but no hint row.
+ expect(frame).not.toContain('⎿');
+ // Both descriptions appear in the wrapped summary (may be split across lines).
+ expect(frame).toContain('a.ts');
+ expect(frame).toContain('Display.tsx');
});
});
@@ -324,13 +325,13 @@ describe('buildToolSummary', () => {
expect(buildToolSummary([make({})], true)).toBe('Reading a.ts');
});
- it('multiple same-type tools use count format', () => {
+ it('multiple same-type tools show descriptions inline when ≤ 3', () => {
const tools = [
make({ callId: 'c1', description: 'a.ts' }),
make({ callId: 'c2', description: 'b.ts' }),
make({ callId: 'c3', description: 'c.ts' }),
];
- expect(buildToolSummary(tools, false)).toBe('Read 3 files');
+ expect(buildToolSummary(tools, false)).toBe('Read a.ts, b.ts, c.ts');
});
it('multiple same-type tools use progressive verb when active', () => {
@@ -338,7 +339,38 @@ describe('buildToolSummary', () => {
make({ callId: 'c1', description: 'a.ts' }),
make({ callId: 'c2', description: 'b.ts' }),
];
- expect(buildToolSummary(tools, true)).toBe('Reading 2 files');
+ expect(buildToolSummary(tools, true)).toBe('Reading a.ts, b.ts');
+ });
+
+ it('multiple same-type tools show first 2 + "...and N more" when > 3', () => {
+ const tools = [
+ make({ callId: 'c1', description: 'a.ts' }),
+ make({ callId: 'c2', description: 'b.ts' }),
+ make({ callId: 'c3', description: 'c.ts' }),
+ make({ callId: 'c4', description: 'd.ts' }),
+ ];
+ expect(buildToolSummary(tools, false)).toBe(
+ 'Read a.ts, b.ts, ... and 2 more',
+ );
+ });
+
+ it('multiple same-type tools fall back to count when descriptions are missing', () => {
+ const tools = [
+ make({ callId: 'c1', description: 'a.ts' }),
+ make({ callId: 'c2', description: '' }),
+ make({ callId: 'c3', description: 'c.ts' }),
+ ];
+ expect(buildToolSummary(tools, false)).toBe('Read 3 files');
+ });
+
+ it('more than 3 tools fall back to count when preview descriptions are missing', () => {
+ const tools = [
+ make({ callId: 'c1', description: '' }),
+ make({ callId: 'c2', description: '' }),
+ make({ callId: 'c3', description: 'c.ts' }),
+ make({ callId: 'c4', description: 'd.ts' }),
+ ];
+ expect(buildToolSummary(tools, false)).toBe('Read 4 files');
});
it('mixed types joined with comma and lowercase verbs', () => {
@@ -441,13 +473,15 @@ describe('buildToolSummary', () => {
expect(buildToolSummary(tools, false)).toBe('Ran echo hello world');
});
- it('mixed group uses count format per category', () => {
+ it('mixed group shows descriptions inline per category', () => {
const tools = [
make({ callId: 'c1', name: 'ReadFile', description: 'a.ts' }),
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 npm test');
+ expect(buildToolSummary(tools, false)).toBe(
+ 'Read a.ts, b.ts, ran npm test',
+ );
});
it('legacy display names map to correct categories', () => {
@@ -478,7 +512,23 @@ describe('estimateCompactToolGroupHeight', () => {
expect(estimateCompactToolGroupHeight([tool], 30)).toBeGreaterThan(1);
});
- it('adds one row for an active batch description hint', () => {
+ it('adds one row for an active batch description hint when > 3 tools', () => {
+ const tools = [
+ toolCall({ callId: 'c1', name: 'ReadFile', description: 'a.ts' }),
+ toolCall({ callId: 'c2', name: 'ReadFile', description: 'b.ts' }),
+ toolCall({ callId: 'c3', name: 'ReadFile', description: 'c.ts' }),
+ toolCall({
+ callId: 'c4',
+ name: 'ReadFile',
+ description: 'd.ts',
+ status: ToolCallStatus.Executing,
+ }),
+ ];
+
+ expect(estimateCompactToolGroupHeight(tools, 80)).toBe(2);
+ });
+
+ it('uses one row for an active batch with ≤ 3 tools (descriptions inline)', () => {
const tools = [
toolCall({ callId: 'c1', name: 'ReadFile', description: 'a.ts' }),
toolCall({
@@ -489,7 +539,7 @@ describe('estimateCompactToolGroupHeight', () => {
}),
];
- expect(estimateCompactToolGroupHeight(tools, 80)).toBe(2);
+ expect(estimateCompactToolGroupHeight(tools, 80)).toBe(1);
});
it('uses one row for a completed batch', () => {
diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
index 9ec24cbfe69..b923ff884da 100644
--- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
@@ -290,6 +290,24 @@ function safeDescription(raw: string | undefined): string | undefined {
return cleaned || undefined;
}
+/**
+ * Whether all tools in the given category have usable descriptions and the
+ * count is within the inline limit — meaning the summary already shows each
+ * description individually, so a separate hint line would be redundant.
+ */
+function categoryShowsDescriptionsInline(
+ toolCalls: IndividualToolCallDisplay[],
+ category: ToolCategory,
+): boolean {
+ const sameCategory = toolCalls.filter(
+ (tc) => getToolCategory(tc.name) === category,
+ );
+ if (sameCategory.length > DESCRIPTION_INLINE_LIMIT) return false;
+ return sameCategory.every(
+ (tc) => safeDescription(tc.description) !== undefined,
+ );
+}
+
function getActiveToolHint(
toolCalls: IndividualToolCallDisplay[],
): string | undefined {
@@ -305,12 +323,16 @@ function getActiveToolHint(
const tool = toolCalls[index];
if (tool.status === status) {
const category = getToolCategory(tool.name);
- const usesCountSummary = toolCalls.some(
+ const hasCategoryPeers = toolCalls.some(
(candidate, candidateIndex) =>
candidateIndex !== index &&
getToolCategory(candidate.name) === category,
);
- return usesCountSummary ? safeDescription(tool.description) : undefined;
+ if (!hasCategoryPeers) return undefined;
+ // Summary already shows descriptions inline → no hint needed.
+ if (categoryShowsDescriptionsInline(toolCalls, category))
+ return undefined;
+ return safeDescription(tool.description);
}
}
}
@@ -318,12 +340,27 @@ function getActiveToolHint(
return undefined;
}
+/**
+ * Maximum number of tools within one category whose individual descriptions
+ * are shown inline. Beyond this, the first `DESCRIPTION_PREVIEW_COUNT` are
+ * shown followed by "...and N more".
+ */
+const DESCRIPTION_INLINE_LIMIT = 3;
+
+/**
+ * Number of descriptions shown as a preview when the category exceeds
+ * `DESCRIPTION_INLINE_LIMIT`.
+ */
+const DESCRIPTION_PREVIEW_COUNT = 2;
+
/**
* Build a semantic summary line from a batch of tool calls.
*
* 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 ≤ 3 (with descriptions) → "Read a.ts, b.ts, c.ts"
+ * Multi ≤ 3 (no descriptions) → "Read 3 files"
+ * Multi > 3 → "Read a.ts, b.ts, ... and 3 more"
* Multi mixed → "Read 2 files, ran npm test"
*
* Uses past tense when all tools are done, present progressive when active.
@@ -352,6 +389,7 @@ export function buildToolSummary(
if (!tools || tools.length === 0) continue;
const template = CATEGORY_TEMPLATES[cat];
+ const verb = isActive ? template.activeVerb : template.pastVerb;
let part: string;
if (tools.length === 1) {
const safeDesc = safeDescription(tools[0].description);
@@ -359,7 +397,6 @@ export function buildToolSummary(
// Single tool with a concrete description: show it ("Read a.ts").
// Verb is English (see CategoryTemplate note) but the description is
// language-neutral, so the line reads correctly in every locale.
- const verb = isActive ? template.activeVerb : template.pastVerb;
part = `${verb} ${safeDesc}`;
} else {
// No usable description → localized count phrase ("Read 1 file").
@@ -367,10 +404,35 @@ export function buildToolSummary(
count: '1',
});
}
+ } else if (tools.length <= DESCRIPTION_INLINE_LIMIT) {
+ // ≤ 3 tools: show all descriptions if available.
+ const descriptions = tools
+ .map((tc) => safeDescription(tc.description))
+ .filter((d): d is string => d !== undefined);
+ if (descriptions.length === tools.length) {
+ part = `${verb} ${descriptions.join(', ')}`;
+ } else {
+ // Not all tools have usable descriptions → count phrase.
+ const forms = isActive ? template.active : template.past;
+ part = t(forms.many, { count: String(tools.length) });
+ }
} else {
- // Multiple tools of one category → localized plural count phrase.
- const forms = isActive ? template.active : template.past;
- part = t(forms.many, { count: String(tools.length) });
+ // > 3 tools: show first N descriptions + "...and M more".
+ const previewDescs = tools
+ .slice(0, DESCRIPTION_PREVIEW_COUNT)
+ .map((tc) => safeDescription(tc.description))
+ .filter((d): d is string => d !== undefined);
+ if (previewDescs.length === DESCRIPTION_PREVIEW_COUNT) {
+ const remaining = tools.length - DESCRIPTION_PREVIEW_COUNT;
+ const morePhrase = t('... and {{count}} more', {
+ count: String(remaining),
+ });
+ part = `${verb} ${previewDescs.join(', ')}, ${morePhrase}`;
+ } else {
+ // Not enough preview descriptions → count phrase.
+ const forms = isActive ? template.active : template.past;
+ part = t(forms.many, { count: String(tools.length) });
+ }
}
// Lowercase the leading character for every part after the first ("Read 3
// files, edited 2 files"). Operating on the first char only keeps already-
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
index b9e709f53d1..1de8e46a67a 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
@@ -190,7 +190,7 @@ describe('', () => {
const frame = lastFrame() ?? '';
// CATEGORY_ORDER: search first (capitalized), then read (lowercased)
expect(frame).toContain('Searched pattern');
- expect(frame).toContain('read 2 files');
+ expect(frame).toContain('read a.ts, b.ts');
expect(frame).not.toContain('MockTool');
});
@@ -338,7 +338,7 @@ describe('', () => {
/>,
);
const frame = lastFrame() ?? '';
- expect(frame).toContain('Read 2 files');
+ expect(frame).toContain('Read a.ts, b.ts');
expect(frame).toContain('Recalled 1 memory');
});
@@ -1072,7 +1072,8 @@ describe('', () => {
const call = vi
.mocked(ToolMessage)
.mock.calls.find((c) => c[0].callId === 'shell-result');
- expect(call?.[0].availableTerminalHeight).toBe(9);
+ // 2 reads inline (no hint row) → summary is 1 row shorter than before.
+ expect(call?.[0].availableTerminalHeight).toBe(10);
});
});