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); }); });