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;