diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 8ed8f27d9..09715b4cd 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -29,7 +29,7 @@ describe('', () => { createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toBe('Hello, world!'); }); @@ -45,7 +45,7 @@ describe('', () => { ]; // Note: ink-testing-library doesn't render styles, so we can only check the text. // We are testing that it renders without crashing. - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse'); }); @@ -58,7 +58,7 @@ describe('', () => { ]; // Note: ink-testing-library doesn't render colors, so we can only check the text. // We are testing that it renders without crashing. - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toBe('Red FGBlue BG'); }); @@ -69,12 +69,15 @@ describe('', () => { [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame } = render(); + const { lastFrame } = render(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output!.split('\n'); expect(lines[0]).toBe('First line'); - expect(lines[1]).toBe('Third line'); + // Empty AnsiLines are preserved as blank rows so shell output layout + // matches the terminal it came from. + expect(lines[1]).toBe(''); + expect(lines[2]).toBe('Third line'); }); it('respects the availableTerminalHeight prop and slices the lines correctly', () => { @@ -85,7 +88,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).not.toContain('Line 1'); @@ -99,8 +102,48 @@ describe('', () => { for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame } = render(); + const { lastFrame } = render( + , + ); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); }); + + it('truncates wide lines to fit within maxWidth', () => { + const wideText = 'A'.repeat(100); + const data: AnsiOutput = [[createAnsiToken({ text: wideText })]]; + const { lastFrame } = render(); + const output = lastFrame()!; + // The line should be truncated to fit within maxWidth + expect(output.length).toBeLessThanOrEqual(20); + }); + + it('truncates multi-token wide lines (styled-column output) to maxWidth', () => { + // Mirrors the real-world shape produced by commands like `gh run list`: + // a single logical row composed of many styled-column tokens whose + // combined width far exceeds the available box width. This exercises + // the MaxSizedBox row.segments.length === 0 path, where truncation + // depends on per-token wrap="truncate" + ink's flex layout rather + // than MaxSizedBox performing the crop itself. + const data: AnsiOutput = [ + [ + createAnsiToken({ text: 'STATUS ', bold: true }), + createAnsiToken({ text: 'TITLE ', bold: true }), + createAnsiToken({ text: 'WORKFLOW ', bold: true }), + createAnsiToken({ text: 'BRANCH ', bold: true }), + createAnsiToken({ text: 'EVENT ', bold: true }), + createAnsiToken({ text: 'ID ', bold: true }), + createAnsiToken({ text: 'ELAPSED ', bold: true }), + createAnsiToken({ text: 'AGE', bold: true }), + ], + ]; + const maxWidth = 30; + const { lastFrame } = render( + , + ); + const output = lastFrame()!; + for (const line of output.split('\n')) { + expect(line.length).toBeLessThanOrEqual(maxWidth); + } + }); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index 263100dca..2ba7229fb 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -5,46 +5,54 @@ */ import type React from 'react'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; import type { AnsiLine, AnsiOutput, AnsiToken, } from '@qwen-code/qwen-code-core'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; const DEFAULT_HEIGHT = 24; interface AnsiOutputProps { data: AnsiOutput; availableTerminalHeight?: number; + maxWidth: number; } export const AnsiOutputText: React.FC = ({ data, availableTerminalHeight, + maxWidth, }) => { const lastLines = data.slice( -(availableTerminalHeight && availableTerminalHeight > 0 ? availableTerminalHeight : DEFAULT_HEIGHT), ); - return lastLines.map((line: AnsiLine, lineIndex: number) => ( - - {line.length > 0 - ? line.map((token: AnsiToken, tokenIndex: number) => ( - - {token.text} - - )) - : null} - - )); + return ( + + {lastLines.map((line: AnsiLine, lineIndex: number) => ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + + ))} + + ); }; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index a0bb30f7a..d95e47c7f 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -35,12 +35,22 @@ vi.mock('../TerminalOutput.js', () => ({ })); vi.mock('../AnsiOutput.js', () => ({ - AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) { + AnsiOutputText: function MockAnsiOutputText({ + data, + maxWidth, + }: { + data: AnsiOutput; + maxWidth: number; + }) { // Simple serialization for snapshot stability const serialized = data .map((line) => line.map((token) => token.text || '').join('')) .join('\n'); - return MockAnsiOutput:{serialized}; + return ( + + MockAnsiOutput:{serialized}:width={maxWidth} + + ); }, })); @@ -315,6 +325,7 @@ describe('', () => { StreamingState.Idle, ); expect(lastFrame()).toContain('MockAnsiOutput:hello'); + expect(lastFrame()).toContain('width='); }); it('renders rejected plan content with plan text still visible', () => { diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 195c4794c..8596a996b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -403,6 +403,7 @@ export const ToolMessage: React.FC = ({ )} {effectiveDisplayRenderer.type === 'string' && (