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' && (