diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx index cbce2e7b87d..be6c5d78ab1 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx @@ -10,6 +10,7 @@ import { Text } from 'ink'; import { CompactToolGroupDisplay, buildToolSummary, + estimateCompactToolGroupHeight, isCollapsibleTool, } from './CompactToolGroupDisplay.js'; import { ToolCallStatus } from '../../types.js'; @@ -147,6 +148,20 @@ describe(' — summary label', () => { ); expect(lastFrame()).toContain('Ran ls -la'); }); + + it('wraps long summaries instead of truncating them', () => { + const description = + 'packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx'; + const tool = toolCall({ name: 'ReadFile', description }); + const { lastFrame } = render( + , + ); + const frame = lastFrame()!; + + expect(frame.split('\n').length).toBeGreaterThan(1); + expect(frame).not.toContain('…'); + expect(frame.replace(/\s/g, '')).toContain(`Read${description}`); + }); }); describe('buildToolSummary', () => { @@ -294,6 +309,67 @@ describe('buildToolSummary', () => { }); }); +describe('estimateCompactToolGroupHeight', () => { + it('returns 0 when there are no tool calls', () => { + expect(estimateCompactToolGroupHeight([], 80)).toBe(0); + }); + + it('returns 1 for summaries that fit on one line', () => { + expect(estimateCompactToolGroupHeight([toolCall()], 80)).toBe(1); + }); + + it('accounts for wrapped long summaries', () => { + const description = + 'packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx'; + const tool = toolCall({ name: 'ReadFile', description }); + + expect(estimateCompactToolGroupHeight([tool], 30)).toBeGreaterThan(1); + }); + + it('reserves additional width for active summary status', () => { + const description = + 'packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx'; + const completed = toolCall({ name: 'ReadFile', description }); + const active = toolCall({ + name: 'ReadFile', + description, + status: ToolCallStatus.Executing, + }); + + expect(estimateCompactToolGroupHeight([active], 30)).toBeGreaterThan( + estimateCompactToolGroupHeight([completed], 30), + ); + }); + + it('reserves timeout label width for active shell summaries', () => { + const description = + 'npm test -- --filter packages/cli/src/ui/components/messages'; + const activeShell = shellTool({ description }); + const activeShellWithTimeout = shellTool({ + description, + resultDisplay: { + ansiOutput: [], + totalLines: 0, + totalBytes: 0, + timeoutMs: 30_000, + }, + }); + + expect( + estimateCompactToolGroupHeight([activeShellWithTimeout], 55), + ).toBeGreaterThan(estimateCompactToolGroupHeight([activeShell], 55)); + }); + + it('uses terminal display width for wide characters', () => { + const tool = toolCall({ + name: 'ReadFile', + description: '中文中文中文中文', + }); + + expect(estimateCompactToolGroupHeight([tool], 12)).toBe(3); + }); +}); + describe('isCollapsibleTool', () => { it('returns true for read/search/list tools', () => { expect(isCollapsibleTool('ReadFile')).toBe(true); diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx index e4c24085197..bb29b2e01fd 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx @@ -6,20 +6,30 @@ import type React from 'react'; import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import wrapAnsi from 'wrap-ansi'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import type { AnsiOutputDisplay } from '@qwen-code/qwen-code-core'; import { ToolDisplayNames } from '@qwen-code/qwen-code-core'; import { t } from '../../../i18n/index.js'; import { SHELL_COMMAND_NAME } from '../../constants.js'; -import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js'; +import { + STATUS_INDICATOR_WIDTH, + ToolStatusIndicator, +} from '../shared/ToolStatusIndicator.js'; import { ToolElapsedTime } from '../shared/ToolElapsedTime.js'; +import { formatDuration } from '../../utils/formatters.js'; interface CompactToolGroupDisplayProps { toolCalls: IndividualToolCallDisplay[]; contentWidth: number; } +const COMPACT_GROUP_HORIZONTAL_PADDING = 2; +const ELAPSED_TIME_MARGIN_LEFT = 1; +const EXECUTING_ELAPSED_TIME_RESERVED_LABEL = '99h 59m 59s'; + // Priority: Confirming > Executing > Error > Canceled > Pending > Success export function getOverallStatus( toolCalls: IndividualToolCallDisplay[], @@ -62,6 +72,32 @@ function getShellTimeoutMs( return undefined; } +function isToolGroupActive(status: ToolCallStatus): boolean { + return ( + status === ToolCallStatus.Executing || + status === ToolCallStatus.Pending || + status === ToolCallStatus.Confirming + ); +} + +function getElapsedTimeReservedWidth( + tool: IndividualToolCallDisplay, + status: ToolCallStatus, +): number { + if (status !== ToolCallStatus.Executing) return 0; + + const timeoutMs = getShellTimeoutMs(tool); + let label = EXECUTING_ELAPSED_TIME_RESERVED_LABEL; + if (timeoutMs != null && timeoutMs > 0) { + const maxElapsedStr = formatDuration(timeoutMs, { + hideTrailingZeros: true, + }); + label = `(${maxElapsedStr} · timeout ${maxElapsedStr})`; + } + + return ELAPSED_TIME_MARGIN_LEFT + stringWidth(label); +} + type ToolCategory = | 'read' | 'edit' @@ -312,6 +348,31 @@ export function buildToolSummary( return parts.join(', '); } +export function estimateCompactToolGroupHeight( + toolCalls: IndividualToolCallDisplay[], + contentWidth: number, +): number { + if (toolCalls.length === 0) return 0; + + const overallStatus = getOverallStatus(toolCalls); + const activeTool = getActiveTool(toolCalls); + const isActive = isToolGroupActive(overallStatus); + const summary = `${buildToolSummary(toolCalls, isActive)}${isActive ? '…' : ''}`; + const summaryWidth = Math.max( + 1, + contentWidth - + COMPACT_GROUP_HORIZONTAL_PADDING - + STATUS_INDICATOR_WIDTH - + getElapsedTimeReservedWidth(activeTool, overallStatus), + ); + const wrappedSummary = wrapAnsi(summary, summaryWidth, { + hard: true, + trim: false, + }); + + return Math.max(1, wrappedSummary.split('\n').length); +} + export const CompactToolGroupDisplay: React.FC< CompactToolGroupDisplayProps > = ({ toolCalls, contentWidth }) => { @@ -319,17 +380,14 @@ export const CompactToolGroupDisplay: React.FC< const overallStatus = getOverallStatus(toolCalls); const activeTool = getActiveTool(toolCalls); - const isActive = - overallStatus === ToolCallStatus.Executing || - overallStatus === ToolCallStatus.Pending || - overallStatus === ToolCallStatus.Confirming; + const isActive = isToolGroupActive(overallStatus); return ( - + {buildToolSummary(toolCalls, isActive)} {isActive && } diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index eeba3874dcc..14e544cd99c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -1002,6 +1002,40 @@ describe('', () => { ); expect(lastFrame()).toMatchSnapshot(); }); + + it('reserves wrapped compact summary height before sizing tool results', () => { + vi.mocked(ToolMessage).mockClear(); + const toolCalls = [ + createToolCall({ + callId: 'read-long', + name: 'ReadFile', + description: + 'packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx', + status: ToolCallStatus.Success, + }), + createToolCall({ + callId: 'shell-result', + name: 'Shell', + description: 'npm test', + status: ToolCallStatus.Success, + resultDisplay: 'shell output', + }), + ]; + + renderWithProviders( + , + ); + + const call = vi + .mocked(ToolMessage) + .mock.calls.find((c) => c[0].callId === 'shell-result'); + expect(call?.[0].availableTerminalHeight).toBe(8); + }); }); describe('Confirmation Handling', () => { diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 5d33bd95faa..07f35913b13 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -13,6 +13,7 @@ import { ToolMessage } from './ToolMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { CompactToolGroupDisplay, + estimateCompactToolGroupHeight, isCollapsibleTool, } from './CompactToolGroupDisplay.js'; import { InlineParallelAgentsDisplay } from './InlineParallelAgentsDisplay.js'; @@ -432,7 +433,10 @@ export const ToolGroupMessage: React.FC = ({ } // Full expanded view for non-collapsible tools - const collapsibleSummaryHeight = collapsibleTools.length > 0 ? 1 : 0; + const collapsibleSummaryHeight = estimateCompactToolGroupHeight( + collapsibleTools, + contentWidth, + ); const memoryBadgeHeight = hasMemoryBadge ? 1 : 0; const staticHeight = /* marginBottom */ 1 + collapsibleSummaryHeight + memoryBadgeHeight;