From 39708ceacb86cf22a82d1c98670f0217179df493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 24 Apr 2026 13:39:28 +0800 Subject: [PATCH 1/2] fix(cli): pre-slice large tool text output --- .../components/messages/ToolMessage.test.tsx | 48 +++++++++++++++++++ .../ui/components/messages/ToolMessage.tsx | 35 ++++++++++++-- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index a8e57c8a18c..84664f5b5f3 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -547,6 +547,54 @@ describe('', () => { expect(output).toContain('line 30'); }); + it('pre-slices large non-shell string output before MaxSizedBox layout', () => { + const longString = Array.from( + { length: 5000 }, + (_, i) => `line ${i + 1}`, + ).join('\n'); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + + expect(output).toContain('... first 4995 lines hidden ...'); + expect(output).not.toContain('line 4995'); + expect(output).toContain('line 4996'); + expect(output).toContain('line 4997'); + expect(output).toContain('line 4998'); + expect(output).toContain('line 4999'); + expect(output).toContain('line 5000'); + }); + + it('does not pre-slice string output that exactly fits available height', () => { + const exactFitString = Array.from( + { length: 6 }, + (_, i) => `line ${i + 1}`, + ).join('\n'); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + + expect(output).not.toContain('lines hidden'); + expect(output).toContain('line 1'); + expect(output).toContain('line 6'); + }); + it.each([ ['negative', -1], ['fractional', 1.5], diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 64f616cfc91..9eb462362f2 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -12,7 +12,7 @@ import { DiffRenderer } from './DiffRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText, ShellStatsBar } from '../AnsiOutput.js'; import type { ShellStatsBarProps } from '../AnsiOutput.js'; -import { MaxSizedBox } from '../shared/MaxSizedBox.js'; +import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { TodoDisplay } from '../TodoDisplay.js'; import type { TodoResultDisplay, @@ -48,6 +48,29 @@ const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5; const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000; export type TextEmphasis = 'high' | 'medium' | 'low'; +function sliceTextForMaxHeight( + text: string, + maxHeight: number | undefined, +): { text: string; hiddenLinesCount: number } { + if (maxHeight === undefined) { + return { text, hiddenLinesCount: 0 }; + } + + const targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT); + const lines = text.split('\n'); + + if (lines.length <= targetMaxHeight) { + return { text, hiddenLinesCount: 0 }; + } + + const visibleContentHeight = targetMaxHeight - 1; + const hiddenLinesCount = lines.length - visibleContentHeight; + return { + text: lines.slice(hiddenLinesCount).join('\n'), + hiddenLinesCount, + }; +} + type DisplayRendererResult = | { type: 'none' } | { type: 'todo'; data: TodoResultDisplay } @@ -234,11 +257,17 @@ const StringResultRenderer: React.FC<{ ); } + const sliced = sliceTextForMaxHeight(displayData, availableHeight); + return ( - + - {displayData} + {sliced.text} From 282c307739b020cb5ba3d5263e151662f0bbfa67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 24 Apr 2026 16:09:51 +0800 Subject: [PATCH 2/2] fix(cli): slice tool output by visual height --- .../components/messages/ToolMessage.test.tsx | 22 ++++++++ .../ui/components/messages/ToolMessage.tsx | 53 ++++++++++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 84664f5b5f3..8e2fb0d3397 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -573,6 +573,28 @@ describe('', () => { expect(output).toContain('line 5000'); }); + it('pre-slices single-line output by visual width before MaxSizedBox layout', () => { + const longSingleLine = Array.from({ length: 1000 }, (_, i) => + String(i % 10), + ).join(''); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + + expect(output).toMatch(/\.\.\. first \d+ lin/); + expect(output).not.toContain(longSingleLine); + expect(output).toContain(longSingleLine.slice(-10)); + }); + it('does not pre-slice string output that exactly fits available height', () => { const exactFitString = Array.from( { length: 6 }, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 9eb462362f2..417c00010b3 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -31,6 +31,7 @@ import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import type { LoadedSettings } from '../../../config/settings.js'; import { useCompactMode } from '../../contexts/CompactModeContext.js'; +import { getCachedStringWidth, toCodePoints } from '../../utils/textUtils.js'; import { ToolStatusIndicator, @@ -51,22 +52,58 @@ export type TextEmphasis = 'high' | 'medium' | 'low'; function sliceTextForMaxHeight( text: string, maxHeight: number | undefined, + maxWidth: number, ): { text: string; hiddenLinesCount: number } { if (maxHeight === undefined) { return { text, hiddenLinesCount: 0 }; } const targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT); - const lines = text.split('\n'); + const visibleContentHeight = targetMaxHeight - 1; + const visualWidth = Math.max(1, Math.floor(maxWidth)); + const visibleLines: string[] = []; + let visualLineCount = 0; + let currentLine = ''; + let currentLineWidth = 0; + + const appendVisibleLine = (line: string) => { + visualLineCount += 1; + visibleLines.push(line); + if (visibleLines.length > visibleContentHeight) { + visibleLines.shift(); + } + }; + + const flushCurrentLine = () => { + appendVisibleLine(currentLine); + currentLine = ''; + currentLineWidth = 0; + }; + + for (const char of toCodePoints(text)) { + if (char === '\n') { + flushCurrentLine(); + continue; + } - if (lines.length <= targetMaxHeight) { + const charWidth = Math.max(getCachedStringWidth(char), 1); + if (currentLineWidth > 0 && currentLineWidth + charWidth > visualWidth) { + flushCurrentLine(); + } + + currentLine += char; + currentLineWidth += charWidth; + } + + flushCurrentLine(); + + if (visualLineCount <= targetMaxHeight) { return { text, hiddenLinesCount: 0 }; } - const visibleContentHeight = targetMaxHeight - 1; - const hiddenLinesCount = lines.length - visibleContentHeight; + const hiddenLinesCount = visualLineCount - visibleContentHeight; return { - text: lines.slice(hiddenLinesCount).join('\n'), + text: visibleLines.join('\n'), hiddenLinesCount, }; } @@ -257,7 +294,11 @@ const StringResultRenderer: React.FC<{ ); } - const sliced = sliceTextForMaxHeight(displayData, availableHeight); + const sliced = sliceTextForMaxHeight( + displayData, + availableHeight, + childWidth, + ); return (