summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components')
-rw-r--r--packages/cli/src/ui/components/DetailedMessagesDisplay.tsx75
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.test.tsx2
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.tsx7
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx3
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx36
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.test.tsx156
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.tsx45
-rw-r--r--packages/cli/src/ui/components/messages/GeminiMessage.tsx5
-rw-r--r--packages/cli/src/ui/components/messages/GeminiMessageContent.tsx5
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx12
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx114
-rw-r--r--packages/cli/src/ui/components/messages/ToolGroupMessage.tsx31
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.test.tsx2
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.tsx89
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx303
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.tsx511
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx21
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts23
18 files changed, 1225 insertions, 215 deletions
diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
index c2ecb803..0c5366cd 100644
--- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
+++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
@@ -8,20 +8,24 @@ import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ConsoleMessageItem } from '../types.js';
+import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
+ maxHeight: number | undefined;
+ width: number;
// debugMode is not needed here if App.tsx filters debug messages before passing them.
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
}
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
-> = ({ messages }) => {
+> = ({ messages, maxHeight, width }) => {
if (messages.length === 0) {
return null; // Don't render anything if there are no messages
}
+ const borderAndPadding = 4;
return (
<Box
flexDirection="column"
@@ -29,47 +33,50 @@ export const DetailedMessagesDisplay: React.FC<
borderStyle="round"
borderColor={Colors.Gray}
paddingX={1}
+ width={width}
>
<Box marginBottom={1}>
<Text bold color={Colors.Foreground}>
Debug Console <Text color={Colors.Gray}>(ctrl+O to close)</Text>
</Text>
</Box>
- {messages.map((msg, index) => {
- let textColor = Colors.Foreground;
- let icon = '\u2139'; // Information source (ℹ)
+ <MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
+ {messages.map((msg, index) => {
+ let textColor = Colors.Foreground;
+ let icon = '\u2139'; // Information source (ℹ)
- switch (msg.type) {
- case 'warn':
- textColor = Colors.AccentYellow;
- icon = '\u26A0'; // Warning sign (⚠)
- break;
- case 'error':
- textColor = Colors.AccentRed;
- icon = '\u2716'; // Heavy multiplication x (✖)
- break;
- case 'debug':
- textColor = Colors.Gray; // Or Colors.Gray
- icon = '\u1F50D'; // Left-pointing magnifying glass (????)
- break;
- case 'log':
- default:
- // Default textColor and icon are already set
- break;
- }
+ switch (msg.type) {
+ case 'warn':
+ textColor = Colors.AccentYellow;
+ icon = '\u26A0'; // Warning sign (⚠)
+ break;
+ case 'error':
+ textColor = Colors.AccentRed;
+ icon = '\u2716'; // Heavy multiplication x (✖)
+ break;
+ case 'debug':
+ textColor = Colors.Gray; // Or Colors.Gray
+ icon = '\u1F50D'; // Left-pointing magnifying glass (????)
+ break;
+ case 'log':
+ default:
+ // Default textColor and icon are already set
+ break;
+ }
- return (
- <Box key={index} flexDirection="row">
- <Text color={textColor}>{icon} </Text>
- <Text color={textColor} wrap="wrap">
- {msg.content}
- {msg.count && msg.count > 1 && (
- <Text color={Colors.Gray}> (x{msg.count})</Text>
- )}
- </Text>
- </Box>
- );
- })}
+ return (
+ <Box key={index} flexDirection="row">
+ <Text color={textColor}>{icon} </Text>
+ <Text color={textColor} wrap="wrap">
+ {msg.content}
+ {msg.count && msg.count > 1 && (
+ <Text color={Colors.Gray}> (x{msg.count})</Text>
+ )}
+ </Text>
+ </Box>
+ );
+ })}
+ </MaxSizedBox>
</Box>
);
};
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index 5999f0ad..464647b0 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -20,7 +20,7 @@ describe('<HistoryItemDisplay />', () => {
id: 1,
timestamp: 12345,
isPending: false,
- availableTerminalHeight: 100,
+ terminalWidth: 80,
};
it('renders UserMessage for "user" type', () => {
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index d99ad503..ec0ef1f6 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -22,7 +22,8 @@ import { Config } from '@gemini-cli/core';
interface HistoryItemDisplayProps {
item: HistoryItem;
- availableTerminalHeight: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
isPending: boolean;
config?: Config;
isFocused?: boolean;
@@ -31,6 +32,7 @@ interface HistoryItemDisplayProps {
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
item,
availableTerminalHeight,
+ terminalWidth,
isPending,
config,
isFocused = true,
@@ -44,6 +46,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
+ terminalWidth={terminalWidth}
/>
)}
{item.type === 'gemini_content' && (
@@ -51,6 +54,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
+ terminalWidth={terminalWidth}
/>
)}
{item.type === 'info' && <InfoMessage text={item.text} />}
@@ -78,6 +82,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
toolCalls={item.tools}
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
+ terminalWidth={terminalWidth}
config={config}
isFocused={isFocused}
/>
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index f9f7ead6..8b897186 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -9,7 +9,8 @@ import { Text, Box, useInput } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
-import { cpSlice, cpLen, TextBuffer } from './shared/text-buffer.js';
+import { TextBuffer } from './shared/text-buffer.js';
+import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import process from 'node:process';
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index eeccee4c..1fa6bee8 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -21,12 +21,16 @@ interface ThemeDialogProps {
onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
}
export function ThemeDialog({
onSelect,
onHighlight,
settings,
+ availableTerminalHeight,
+ terminalWidth,
}: ThemeDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
@@ -94,6 +98,34 @@ export function ThemeDialog({
: `(Modified in ${otherScope})`;
}
+ // Constants for calculating preview pane layout.
+ // These values are based on the JSX structure below.
+ const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
+ // A safety margin to prevent text from touching the border.
+ // This is a complete hack unrelated to the 0.9 used in App.tsx
+ const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
+ // Combined horizontal padding from the dialog and preview pane.
+ const TOTAL_HORIZONTAL_PADDING = 4;
+ const colorizeCodeWidth = Math.max(
+ Math.floor(
+ (terminalWidth - TOTAL_HORIZONTAL_PADDING) *
+ PREVIEW_PANE_WIDTH_PERCENTAGE *
+ PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
+ ),
+ 1,
+ );
+
+ // Vertical space taken by elements other than the two code blocks in the preview pane.
+ // Includes "Preview" title, borders, padding, and margin between blocks.
+ const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 7;
+ const availableTerminalHeightCodeBlock = availableTerminalHeight
+ ? Math.max(
+ Math.floor(
+ (availableTerminalHeight - PREVIEW_PANE_FIXED_VERTICAL_SPACE) / 2,
+ ),
+ 2,
+ )
+ : undefined;
return (
<Box
borderStyle="round"
@@ -155,6 +187,8 @@ def fibonacci(n):
a, b = b, a + b
return a`,
'python',
+ availableTerminalHeightCodeBlock,
+ colorizeCodeWidth,
)}
<Box marginTop={1} />
<DiffRenderer
@@ -165,6 +199,8 @@ def fibonacci(n):
-This line was deleted.
+This line was added.
`}
+ availableTerminalHeight={availableTerminalHeightCodeBlock}
+ terminalWidth={colorizeCodeWidth}
/>
</Box>
</Box>
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
index 8c2bba43..52152f55 100644
--- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
@@ -16,6 +16,9 @@ describe('<DiffRenderer />', () => {
mockColorizeCode.mockClear();
});
+ const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
+ output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
+
it('should call colorizeCode with correct language for new file with known extension', () => {
const newFileDiffContent = `
diff --git a/test.py b/test.py
@@ -27,11 +30,17 @@ index 0000000..e69de29
+print("hello world")
`;
render(
- <DiffRenderer diffContent={newFileDiffContent} filename="test.py" />,
+ <DiffRenderer
+ diffContent={newFileDiffContent}
+ filename="test.py"
+ terminalWidth={80}
+ />,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")',
'python',
+ undefined,
+ 80,
);
});
@@ -46,9 +55,18 @@ index 0000000..e69de29
+some content
`;
render(
- <DiffRenderer diffContent={newFileDiffContent} filename="test.unknown" />,
+ <DiffRenderer
+ diffContent={newFileDiffContent}
+ filename="test.unknown"
+ terminalWidth={80}
+ />,
+ );
+ expect(mockColorizeCode).toHaveBeenCalledWith(
+ 'some content',
+ null,
+ undefined,
+ 80,
);
- expect(mockColorizeCode).toHaveBeenCalledWith('some content', null);
});
it('should call colorizeCode with null language for new file if no filename is provided', () => {
@@ -61,8 +79,15 @@ index 0000000..e69de29
@@ -0,0 +1 @@
+some text content
`;
- render(<DiffRenderer diffContent={newFileDiffContent} />);
- expect(mockColorizeCode).toHaveBeenCalledWith('some text content', null);
+ render(
+ <DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />,
+ );
+ expect(mockColorizeCode).toHaveBeenCalledWith(
+ 'some text content',
+ null,
+ undefined,
+ 80,
+ );
});
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
@@ -79,6 +104,7 @@ index 0000001..0000002 100644
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
+ terminalWidth={80}
/>,
);
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
@@ -103,14 +129,20 @@ index 1234567..1234567 100644
+++ b/file.txt
`;
const { lastFrame } = render(
- <DiffRenderer diffContent={noChangeDiff} filename="file.txt" />,
+ <DiffRenderer
+ diffContent={noChangeDiff}
+ filename="file.txt"
+ terminalWidth={80}
+ />,
);
expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should handle empty diff content', () => {
- const { lastFrame } = render(<DiffRenderer diffContent="" />);
+ const { lastFrame } = render(
+ <DiffRenderer diffContent="" terminalWidth={80} />,
+ );
expect(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
@@ -130,7 +162,11 @@ index 123..456 100644
context line 11
`;
const { lastFrame } = render(
- <DiffRenderer diffContent={diffWithGap} filename="file.txt" />,
+ <DiffRenderer
+ diffContent={diffWithGap}
+ filename="file.txt"
+ terminalWidth={80}
+ />,
);
const output = lastFrame();
expect(output).toContain('═'); // Check for the border character used in the gap
@@ -161,7 +197,11 @@ index abc..def 100644
context line 15
`;
const { lastFrame } = render(
- <DiffRenderer diffContent={diffWithSmallGap} filename="file.txt" />,
+ <DiffRenderer
+ diffContent={diffWithSmallGap}
+ filename="file.txt"
+ terminalWidth={80}
+ />,
);
const output = lastFrame();
expect(output).not.toContain('═'); // Ensure no separator is rendered
@@ -171,7 +211,7 @@ index abc..def 100644
expect(output).toContain('context line 11');
});
- it('should correctly render a diff with multiple hunks and a gap indicator', () => {
+ describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
const diffWithMultipleHunks = `
diff --git a/multi.js b/multi.js
index 123..789 100644
@@ -188,25 +228,56 @@ index 123..789 100644
+const anotherNew = 'test';
console.log('end of second hunk');
`;
- const { lastFrame } = render(
- <DiffRenderer diffContent={diffWithMultipleHunks} filename="multi.js" />,
- );
- const output = lastFrame();
-
- // Check for content from the first hunk
- expect(output).toContain("1 console.log('first hunk');");
- expect(output).toContain('2 - const oldVar = 1;');
- expect(output).toContain('2 + const newVar = 1;');
- expect(output).toContain("3 console.log('end of first hunk');");
- // Check for the gap indicator between hunks
- expect(output).toContain('═');
-
- // Check for content from the second hunk
- expect(output).toContain("20 console.log('second hunk');");
- expect(output).toContain("21 - const anotherOld = 'test';");
- expect(output).toContain("21 + const anotherNew = 'test';");
- expect(output).toContain("22 console.log('end of second hunk');");
+ it.each([
+ {
+ terminalWidth: 80,
+ height: undefined,
+ expected: `1 console.log('first hunk');
+2 - const oldVar = 1;
+2 + const newVar = 1;
+3 console.log('end of first hunk');
+════════════════════════════════════════════════════════════════════════════════
+20 console.log('second hunk');
+21 - const anotherOld = 'test';
+21 + const anotherNew = 'test';
+22 console.log('end of second hunk');`,
+ },
+ {
+ terminalWidth: 80,
+ height: 6,
+ expected: `... first 4 lines hidden ...
+════════════════════════════════════════════════════════════════════════════════
+20 console.log('second hunk');
+21 - const anotherOld = 'test';
+21 + const anotherNew = 'test';
+22 console.log('end of second hunk');`,
+ },
+ {
+ terminalWidth: 30,
+ height: 6,
+ expected: `... first 10 lines hidden ...
+ 'test';
+21 + const anotherNew =
+ 'test';
+22 console.log('end of
+ second hunk');`,
+ },
+ ])(
+ 'with terminalWidth $terminalWidth and height $height',
+ ({ terminalWidth, height, expected }) => {
+ const { lastFrame } = render(
+ <DiffRenderer
+ diffContent={diffWithMultipleHunks}
+ filename="multi.js"
+ terminalWidth={terminalWidth}
+ availableTerminalHeight={height}
+ />,
+ );
+ const output = lastFrame();
+ expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
+ },
+ );
});
it('should correctly render a diff with a SVN diff format', () => {
@@ -226,15 +297,19 @@ fileDiff Index: file.txt
\\ No newline at end of file
`;
const { lastFrame } = render(
- <DiffRenderer diffContent={newFileDiff} filename="TEST" />,
+ <DiffRenderer
+ diffContent={newFileDiff}
+ filename="TEST"
+ terminalWidth={80}
+ />,
);
const output = lastFrame();
- expect(output).toContain('1 - const oldVar = 1;');
- expect(output).toContain('1 + const newVar = 1;');
- expect(output).toContain('═');
- expect(output).toContain("20 - const anotherOld = 'test';");
- expect(output).toContain("20 + const anotherNew = 'test';");
+ expect(output).toEqual(`1 - const oldVar = 1;
+1 + const newVar = 1;
+════════════════════════════════════════════════════════════════════════════════
+20 - const anotherOld = 'test';
+20 + const anotherNew = 'test';`);
});
it('should correctly render a new file with no file extension correctly', () => {
@@ -250,12 +325,15 @@ fileDiff Index: Dockerfile
\\ No newline at end of file
`;
const { lastFrame } = render(
- <DiffRenderer diffContent={newFileDiff} filename="Dockerfile" />,
+ <DiffRenderer
+ diffContent={newFileDiff}
+ filename="Dockerfile"
+ terminalWidth={80}
+ />,
);
const output = lastFrame();
-
- expect(output).toContain('1 FROM node:14');
- expect(output).toContain('2 RUN npm install');
- expect(output).toContain('3 RUN npm run build');
+ expect(output).toEqual(`1 FROM node:14
+2 RUN npm install
+3 RUN npm run build`);
});
});
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
index 0b35e32d..25fb293e 100644
--- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'crypto';
import { colorizeCode } from '../../utils/CodeColorizer.js';
+import { MaxSizedBox } from '../shared/MaxSizedBox.js';
interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
@@ -90,6 +91,8 @@ interface DiffRendererProps {
diffContent: string;
filename?: string;
tabWidth?: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@@ -98,6 +101,8 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
diffContent,
filename,
tabWidth = DEFAULT_TAB_WIDTH,
+ availableTerminalHeight,
+ terminalWidth,
}) => {
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
@@ -136,9 +141,20 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
- renderedOutput = colorizeCode(addedContent, language);
+ renderedOutput = colorizeCode(
+ addedContent,
+ language,
+ availableTerminalHeight,
+ terminalWidth,
+ );
} else {
- renderedOutput = renderDiffContent(parsedLines, filename, tabWidth);
+ renderedOutput = renderDiffContent(
+ parsedLines,
+ filename,
+ tabWidth,
+ availableTerminalHeight,
+ terminalWidth,
+ );
}
return renderedOutput;
@@ -146,8 +162,10 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
const renderDiffContent = (
parsedLines: DiffLine[],
- filename?: string,
+ filename: string | undefined,
tabWidth = DEFAULT_TAB_WIDTH,
+ availableTerminalHeight: number | undefined,
+ terminalWidth: number,
) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
@@ -191,7 +209,11 @@ const renderDiffContent = (
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
return (
- <Box flexDirection="column" key={key}>
+ <MaxSizedBox
+ maxHeight={availableTerminalHeight}
+ maxWidth={terminalWidth}
+ key={key}
+ >
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
// Determine the relevant line number for gap calculation based on type
let relevantLineNumberForGapCalc: number | null = null;
@@ -209,16 +231,9 @@ const renderDiffContent = (
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
) {
acc.push(
- <Box
- key={`gap-${index}`}
- width="100%"
- borderTop={true}
- borderBottom={false}
- borderRight={false}
- borderLeft={false}
- borderStyle="double"
- borderColor={Colors.Gray}
- ></Box>,
+ <Box key={`gap-${index}`}>
+ <Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
+ </Box>,
);
}
@@ -271,7 +286,7 @@ const renderDiffContent = (
);
return acc;
}, [])}
- </Box>
+ </MaxSizedBox>
);
};
diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
index df8d0a87..9863acd6 100644
--- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx
+++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
@@ -12,13 +12,15 @@ import { Colors } from '../../colors.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
- availableTerminalHeight: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
+ terminalWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
@@ -33,6 +35,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
+ terminalWidth={terminalWidth}
/>
</Box>
</Box>
diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
index da6e468a..b5f01599 100644
--- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
+++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
@@ -11,7 +11,8 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
- availableTerminalHeight: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
}
/*
@@ -24,6 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
+ terminalWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
@@ -34,6 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
+ terminalWidth={terminalWidth}
/>
</Box>
);
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
index a2d76247..6af03d54 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
@@ -20,7 +20,11 @@ describe('ToolConfirmationMessage', () => {
};
const { lastFrame } = render(
- <ToolConfirmationMessage confirmationDetails={confirmationDetails} />,
+ <ToolConfirmationMessage
+ confirmationDetails={confirmationDetails}
+ availableTerminalHeight={30}
+ terminalWidth={80}
+ />,
);
expect(lastFrame()).not.toContain('URLs to fetch:');
@@ -39,7 +43,11 @@ describe('ToolConfirmationMessage', () => {
};
const { lastFrame } = render(
- <ToolConfirmationMessage confirmationDetails={confirmationDetails} />,
+ <ToolConfirmationMessage
+ confirmationDetails={confirmationDetails}
+ availableTerminalHeight={30}
+ terminalWidth={80}
+ />,
);
expect(lastFrame()).toContain('URLs to fetch:');
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index e1e53ff6..4f2c31d3 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -19,17 +19,26 @@ import {
RadioButtonSelect,
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
+import { MaxSizedBox } from '../shared/MaxSizedBox.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
config?: Config;
isFocused?: boolean;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
}
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
-> = ({ confirmationDetails, isFocused = true }) => {
+> = ({
+ confirmationDetails,
+ isFocused = true,
+ availableTerminalHeight,
+ terminalWidth,
+}) => {
const { onConfirm } = confirmationDetails;
+ const childWidth = terminalWidth - 2; // 2 for padding
useInput((_, key) => {
if (!isFocused) return;
@@ -47,6 +56,35 @@ export const ToolConfirmationMessage: React.FC<
RadioSelectItem<ToolConfirmationOutcome>
>();
+ // Body content is now the DiffRenderer, passing filename to it
+ // The bordered box is removed from here and handled within DiffRenderer
+
+ function availableBodyContentHeight() {
+ if (options.length === 0) {
+ // This should not happen in practice as options are always added before this is called.
+ throw new Error('Options not provided for confirmation message');
+ }
+
+ if (availableTerminalHeight === undefined) {
+ return undefined;
+ }
+
+ // Calculate the vertical space (in lines) consumed by UI elements
+ // surrounding the main body content.
+ const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
+ const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
+ const HEIGHT_QUESTION = 1; // The question text is one line.
+ const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
+ const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
+
+ const surroundingElementsHeight =
+ PADDING_OUTER_Y +
+ MARGIN_BODY_BOTTOM +
+ HEIGHT_QUESTION +
+ MARGIN_QUESTION_BOTTOM +
+ HEIGHT_OPTIONS;
+ return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
+ }
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -66,15 +104,6 @@ export const ToolConfirmationMessage: React.FC<
);
}
- // Body content is now the DiffRenderer, passing filename to it
- // The bordered box is removed from here and handled within DiffRenderer
- bodyContent = (
- <DiffRenderer
- diffContent={confirmationDetails.fileDiff}
- filename={confirmationDetails.fileName}
- />
- );
-
question = `Apply this change?`;
options.push(
{
@@ -91,18 +120,18 @@ export const ToolConfirmationMessage: React.FC<
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
+ bodyContent = (
+ <DiffRenderer
+ diffContent={confirmationDetails.fileDiff}
+ filename={confirmationDetails.fileName}
+ availableTerminalHeight={availableBodyContentHeight()}
+ terminalWidth={childWidth}
+ />
+ );
} else if (confirmationDetails.type === 'exec') {
const executionProps =
confirmationDetails as ToolExecuteConfirmationDetails;
- bodyContent = (
- <Box flexDirection="column">
- <Box paddingX={1} marginLeft={1}>
- <Text color={Colors.AccentCyan}>{executionProps.command}</Text>
- </Box>
- </Box>
- );
-
question = `Allow execution?`;
options.push(
{
@@ -115,12 +144,44 @@ export const ToolConfirmationMessage: React.FC<
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
+
+ let bodyContentHeight = availableBodyContentHeight();
+ if (bodyContentHeight !== undefined) {
+ bodyContentHeight -= 2; // Account for padding;
+ }
+ bodyContent = (
+ <Box flexDirection="column">
+ <Box paddingX={1} marginLeft={1}>
+ <MaxSizedBox
+ maxHeight={bodyContentHeight}
+ maxWidth={Math.max(childWidth - 4, 1)}
+ >
+ <Box>
+ <Text color={Colors.AccentCyan}>{executionProps.command}</Text>
+ </Box>
+ </MaxSizedBox>
+ </Box>
+ </Box>
+ );
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
+ question = `Do you want to proceed?`;
+ options.push(
+ {
+ label: 'Yes, allow once',
+ value: ToolConfirmationOutcome.ProceedOnce,
+ },
+ {
+ label: 'Yes, allow always',
+ value: ToolConfirmationOutcome.ProceedAlways,
+ },
+ { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
+ );
+
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
@@ -134,19 +195,6 @@ export const ToolConfirmationMessage: React.FC<
)}
</Box>
);
-
- question = `Do you want to proceed?`;
- options.push(
- {
- label: 'Yes, allow once',
- value: ToolConfirmationOutcome.ProceedOnce,
- },
- {
- label: 'Yes, allow always',
- value: ToolConfirmationOutcome.ProceedAlways,
- },
- { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
- );
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
@@ -177,7 +225,7 @@ export const ToolConfirmationMessage: React.FC<
}
return (
- <Box flexDirection="column" padding={1} minWidth="90%">
+ <Box flexDirection="column" padding={1} width={childWidth}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
@@ -186,7 +234,7 @@ export const ToolConfirmationMessage: React.FC<
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
- <Text>{question}</Text>
+ <Text wrap="truncate">{question}</Text>
</Box>
{/* Select Input for Options */}
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index 8ce40893..445a157c 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -15,7 +15,8 @@ import { Config } from '@gemini-cli/core';
interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
- availableTerminalHeight: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
config?: Config;
isFocused?: boolean;
}
@@ -24,6 +25,7 @@ interface ToolGroupMessageProps {
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
+ terminalWidth,
config,
isFocused = true,
}) => {
@@ -33,6 +35,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
+ // This is a bit of a magic number, but it accounts for the border and
+ // marginLeft.
+ const innerWidth = terminalWidth - 4;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
@@ -41,6 +46,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
[toolCalls],
);
+ let countToolCallsWithResults = 0;
+ for (const tool of toolCalls) {
+ if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
+ countToolCallsWithResults++;
+ }
+ }
+ const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults;
+ const availableTerminalHeightPerToolMessage = availableTerminalHeight
+ ? Math.max(
+ Math.floor(
+ (availableTerminalHeight - staticHeight - countOneLineToolCalls) /
+ Math.max(1, countToolCallsWithResults),
+ ),
+ 1,
+ )
+ : undefined;
+
return (
<Box
flexDirection="column"
@@ -69,7 +91,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
resultDisplay={tool.resultDisplay}
status={tool.status}
confirmationDetails={tool.confirmationDetails}
- availableTerminalHeight={availableTerminalHeight - staticHeight}
+ availableTerminalHeight={availableTerminalHeightPerToolMessage}
+ terminalWidth={innerWidth}
emphasis={
isConfirming
? 'high'
@@ -87,6 +110,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
+ availableTerminalHeight={
+ availableTerminalHeightPerToolMessage
+ }
+ terminalWidth={innerWidth}
/>
)}
</Box>
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index 2b96f18a..74e6709a 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -60,7 +60,7 @@ describe('<ToolMessage />', () => {
description: 'A tool for testing',
resultDisplay: 'Test result',
status: ToolCallStatus.Success,
- availableTerminalHeight: 20,
+ terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
};
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 230f651c..dd23381e 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -11,17 +11,18 @@ import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
+import { MaxSizedBox } from '../shared/MaxSizedBox.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const STATUS_INDICATOR_WIDTH = 3;
const MIN_LINES_SHOWN = 2; // show at least this many lines
-const MIN_LINES_HIDDEN = 3; // hide at least this many lines (or don't hide any)
export type TextEmphasis = 'high' | 'medium' | 'low';
export interface ToolMessageProps extends IndividualToolCallDisplay {
- availableTerminalHeight: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean;
}
@@ -32,36 +33,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
resultDisplay,
status,
availableTerminalHeight,
+ terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
}) => {
- const resultIsString =
- typeof resultDisplay === 'string' && resultDisplay.trim().length > 0;
- const lines = React.useMemo(
- () => (resultIsString ? resultDisplay.split('\n') : []),
- [resultIsString, resultDisplay],
- );
- let contentHeightEstimate = Math.max(
- availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
- MIN_LINES_SHOWN + 1, // enforce minimum lines shown
- );
- // enforce minimum lines hidden (don't hide any otherwise)
- if (lines.length - contentHeightEstimate < MIN_LINES_HIDDEN) {
- contentHeightEstimate = lines.length;
- }
+ const availableHeight = availableTerminalHeight
+ ? Math.max(
+ availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
+ MIN_LINES_SHOWN + 1, // enforce minimum lines shown
+ )
+ : undefined;
- // Truncate the overall string content if it's too long.
- // MarkdownRenderer will handle specific truncation for code blocks within this content.
- // Estimate available height for this specific tool message content area
- // This is a rough estimate; ideally, we'd have a more precise measurement.
- const displayableResult = React.useMemo(
- () =>
- resultIsString
- ? lines.slice(-contentHeightEstimate).join('\n')
- : resultDisplay,
- [lines, resultIsString, contentHeightEstimate, resultDisplay],
- );
- const hiddenLines = Math.max(0, lines.length - contentHeightEstimate);
+ const childWidth = terminalWidth - 3; // account for padding.
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
@@ -75,37 +58,32 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
/>
{emphasis === 'high' && <TrailingIndicator />}
</Box>
- {displayableResult && (
+ {resultDisplay && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
- {hiddenLines > 0 && (
- <Box>
- <Text color={Colors.Gray}>
- ... first {hiddenLines} line{hiddenLines === 1 ? '' : 's'}{' '}
- hidden ...
- </Text>
+ {typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
+ <Box flexDirection="column">
+ <MarkdownDisplay
+ text={resultDisplay}
+ isPending={false}
+ availableTerminalHeight={availableHeight}
+ terminalWidth={childWidth}
+ />
</Box>
)}
- {typeof displayableResult === 'string' &&
- renderOutputAsMarkdown && (
- <Box flexDirection="column">
- <MarkdownDisplay
- text={displayableResult}
- isPending={false}
- availableTerminalHeight={availableTerminalHeight}
- />
+ {typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
+ <MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
+ <Box>
+ <Text wrap="wrap">{resultDisplay}</Text>
</Box>
- )}
- {typeof displayableResult === 'string' &&
- !renderOutputAsMarkdown && (
- <Box flexDirection="column">
- <Text>{displayableResult}</Text>
- </Box>
- )}
- {typeof displayableResult !== 'string' && (
+ </MaxSizedBox>
+ )}
+ {typeof resultDisplay !== 'string' && (
<DiffRenderer
- diffContent={displayableResult.fileDiff}
- filename={displayableResult.fileName}
+ diffContent={resultDisplay.fileDiff}
+ filename={resultDisplay.fileName}
+ availableTerminalHeight={availableHeight}
+ terminalWidth={childWidth}
/>
)}
</Box>
@@ -193,5 +171,8 @@ const ToolInfo: React.FC<ToolInfo> = ({
};
const TrailingIndicator: React.FC = () => (
- <Text color={Colors.Foreground}> ←</Text>
+ <Text color={Colors.Foreground} wrap="truncate">
+ {' '}
+ ←
+ </Text>
);
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
new file mode 100644
index 00000000..23ef98cd
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
@@ -0,0 +1,303 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { MaxSizedBox } from './MaxSizedBox.js';
+import { Box, Text } from 'ink';
+import { describe, it, expect } from 'vitest';
+
+describe('<MaxSizedBox />', () => {
+ it('renders children without truncation when they fit', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={10}>
+ <Box>
+ <Text>Hello, World!</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals('Hello, World!');
+ });
+
+ it('hides lines when content exceeds maxHeight', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={2}>
+ <Box>
+ <Text>Line 1</Text>
+ </Box>
+ <Box>
+ <Text>Line 2</Text>
+ </Box>
+ <Box>
+ <Text>Line 3</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals(`... first 2 lines hidden ...
+Line 3`);
+ });
+
+ it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
+ <Box>
+ <Text>Line 1</Text>
+ </Box>
+ <Box>
+ <Text>Line 2</Text>
+ </Box>
+ <Box>
+ <Text>Line 3</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals(`Line 1
+... last 2 lines hidden ...`);
+ });
+
+ it('wraps text that exceeds maxWidth', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={10} maxHeight={5}>
+ <Box>
+ <Text wrap="wrap">This is a long line of text</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ expect(lastFrame()).equals(`This is a
+long line
+of text`);
+ });
+
+ it('handles mixed wrapping and non-wrapping segments', () => {
+ const multilineText = `This part will wrap around.
+And has a line break.
+ Leading spaces preserved.`;
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={20} maxHeight={20}>
+ <Box>
+ <Text>Example</Text>
+ </Box>
+ <Box>
+ <Text>No Wrap: </Text>
+ <Text wrap="wrap">{multilineText}</Text>
+ </Box>
+ <Box>
+ <Text>Longer No Wrap: </Text>
+ <Text wrap="wrap">This part will wrap around.</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ expect(lastFrame()).equals(
+ `Example
+No Wrap: This part
+ will wrap
+ around.
+ And has a
+ line break.
+ Leading
+ spaces
+ preserved.
+Longer No Wrap: This
+ part
+ will
+ wrap
+ arou
+ nd.`,
+ );
+ });
+
+ it('handles words longer than maxWidth by splitting them', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={5} maxHeight={5}>
+ <Box>
+ <Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ expect(lastFrame()).equals(`... …
+istic
+expia
+lidoc
+ious`);
+ });
+
+ it('does not truncate when maxHeight is undefined', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={undefined}>
+ <Box>
+ <Text>Line 1</Text>
+ </Box>
+ <Box>
+ <Text>Line 2</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals(`Line 1
+Line 2`);
+ });
+
+ it('shows plural "lines" when more than one line is hidden', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={2}>
+ <Box>
+ <Text>Line 1</Text>
+ </Box>
+ <Box>
+ <Text>Line 2</Text>
+ </Box>
+ <Box>
+ <Text>Line 3</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals(`... first 2 lines hidden ...
+Line 3`);
+ });
+
+ it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
+ <Box>
+ <Text>Line 1</Text>
+ </Box>
+ <Box>
+ <Text>Line 2</Text>
+ </Box>
+ <Box>
+ <Text>Line 3</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals(`Line 1
+... last 2 lines hidden ...`);
+ });
+
+ it('renders an empty box for empty children', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>,
+ );
+ // Expect an empty string or a box with nothing in it.
+ // Ink renders an empty box as an empty string.
+ expect(lastFrame()).equals('');
+ });
+
+ it('wraps text with multi-byte unicode characters correctly', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={5} maxHeight={5}>
+ <Box>
+ <Text wrap="wrap">你好世界</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ // "你好" has a visual width of 4. "世界" has a visual width of 4.
+ // With maxWidth=5, it should wrap after the second character.
+ expect(lastFrame()).equals(`你好
+世界`);
+ });
+
+ it('wraps text with multi-byte emoji characters correctly', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={5} maxHeight={5}>
+ <Box>
+ <Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ // Each "🐶" has a visual width of 2.
+ // With maxWidth=5, it should wrap every 2 emojis.
+ expect(lastFrame()).equals(`🐶🐶
+🐶🐶
+🐶`);
+ });
+
+ it('accounts for additionalHiddenLinesCount', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
+ <Box>
+ <Text>Line 1</Text>
+ </Box>
+ <Box>
+ <Text>Line 2</Text>
+ </Box>
+ <Box>
+ <Text>Line 3</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ // 1 line is hidden by overflow, 5 are additionally hidden.
+ expect(lastFrame()).equals(`... first 7 lines hidden ...
+Line 3`);
+ });
+
+ it('handles React.Fragment as a child', () => {
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={10}>
+ <>
+ <Box>
+ <Text>Line 1 from Fragment</Text>
+ </Box>
+ <Box>
+ <Text>Line 2 from Fragment</Text>
+ </Box>
+ </>
+ <Box>
+ <Text>Line 3 direct child</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+ expect(lastFrame()).equals(`Line 1 from Fragment
+Line 2 from Fragment
+Line 3 direct child`);
+ });
+
+ it('clips a long single text child from the top', () => {
+ const THIRTY_LINES = Array.from(
+ { length: 30 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={10}>
+ <Box>
+ <Text>{THIRTY_LINES}</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ const expected = [
+ '... first 21 lines hidden ...',
+ ...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
+ ].join('\n');
+
+ expect(lastFrame()).equals(expected);
+ });
+
+ it('clips a long single text child from the bottom', () => {
+ const THIRTY_LINES = Array.from(
+ { length: 30 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+
+ const { lastFrame } = render(
+ <MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
+ <Box>
+ <Text>{THIRTY_LINES}</Text>
+ </Box>
+ </MaxSizedBox>,
+ );
+
+ const expected = [
+ ...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
+ '... last 21 lines hidden ...',
+ ].join('\n');
+
+ expect(lastFrame()).equals(expected);
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
new file mode 100644
index 00000000..fe73c250
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -0,0 +1,511 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { Fragment } from 'react';
+import { Box, Text } from 'ink';
+import stringWidth from 'string-width';
+import { Colors } from '../../colors.js';
+import { toCodePoints } from '../../utils/textUtils.js';
+
+const enableDebugLog = true;
+
+function debugReportError(message: string, element: React.ReactNode) {
+ if (!enableDebugLog) return;
+
+ if (!React.isValidElement(element)) {
+ console.error(
+ message,
+ `Invalid element: '${String(element)}' typeof=${typeof element}`,
+ );
+ return;
+ }
+
+ let sourceMessage = '<Unknown file>';
+ try {
+ const elementWithSource = element as {
+ _source?: { fileName?: string; lineNumber?: number };
+ };
+ const fileName = elementWithSource._source?.fileName;
+ const lineNumber = elementWithSource._source?.lineNumber;
+ sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
+ } catch (error) {
+ console.error('Error while trying to get file name:', error);
+ }
+
+ console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
+}
+interface MaxSizedBoxProps {
+ children?: React.ReactNode;
+ maxWidth?: number;
+ maxHeight: number | undefined;
+ overflowDirection?: 'top' | 'bottom';
+ additionalHiddenLinesCount?: number;
+}
+
+/**
+ * A React component that constrains the size of its children and provides
+ * content-aware truncation when the content exceeds the specified `maxHeight`.
+ *
+ * `MaxSizedBox` requires a specific structure for its children to correctly
+ * measure and render the content:
+ *
+ * 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
+ * single row of content.
+ * 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
+ * `<Text>` elements can be nested and there are no restrictions to Text
+ * element styling other than that non-wrapping text elements must be
+ * before wrapping text elements.
+ *
+ * **Constraints:**
+ * - **Box Properties:** Custom properties on the child `<Box>` elements are
+ * ignored. In debug mode, runtime checks will report errors for any
+ * unsupported properties.
+ * - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
+ * (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
+ * - **Element Types:** Runtime checks will warn if unsupported element types
+ * are used as children.
+ *
+ * @example
+ * <MaxSizedBox maxWidth={80} maxHeight={10}>
+ * <Box>
+ * <Text>This is the first line.</Text>
+ * </Box>
+ * <Box>
+ * <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
+ * <Text>This is the rest of the line which will wrap if it's too long.</Text>
+ * </Box>
+ * <Box>
+ * <Text>
+ * Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
+ * </Text>
+ * </Box>
+ * </MaxSizedBox>
+ */
+export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
+ children,
+ maxWidth,
+ maxHeight,
+ overflowDirection = 'top',
+ additionalHiddenLinesCount = 0,
+}) => {
+ // When maxHeight is not set, we render the content normally rather
+ // than using our custom layout logic. This should slightly improve
+ // performance for the case where there is no height limit and is
+ // a useful debugging tool to ensure that our layouts are consist
+ // with the expected layout when there is no height limit.
+ // In the future we might choose to still apply our layout logic
+ // even in this case particularlly if there are cases where we
+ // intentionally diverse how certain layouts are rendered.
+ if (maxHeight === undefined) {
+ return (
+ <Box width={maxWidth} height={maxHeight} flexDirection="column">
+ {children}
+ </Box>
+ );
+ }
+
+ if (maxWidth === undefined) {
+ throw new Error('maxWidth must be defined when maxHeight is set.');
+ }
+
+ const laidOutStyledText: StyledText[][] = [];
+ function visitRows(element: React.ReactNode) {
+ if (!React.isValidElement(element)) {
+ return;
+ }
+ if (element.type === Fragment) {
+ React.Children.forEach(element.props.children, visitRows);
+ return;
+ }
+ if (element.type === Box) {
+ layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
+ return;
+ }
+
+ debugReportError('MaxSizedBox children must be <Box> elements', element);
+ }
+
+ React.Children.forEach(children, visitRows);
+
+ const contentWillOverflow =
+ (laidOutStyledText.length > maxHeight && maxHeight > 0) ||
+ additionalHiddenLinesCount > 0;
+ const visibleContentHeight = contentWillOverflow ? maxHeight - 1 : maxHeight;
+
+ const hiddenLinesCount = Math.max(
+ 0,
+ laidOutStyledText.length - visibleContentHeight,
+ );
+ const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
+
+ const visibleStyledText =
+ hiddenLinesCount > 0
+ ? overflowDirection === 'top'
+ ? laidOutStyledText.slice(
+ laidOutStyledText.length - visibleContentHeight,
+ )
+ : laidOutStyledText.slice(0, visibleContentHeight)
+ : laidOutStyledText;
+
+ const visibleLines = visibleStyledText.map((line, index) => (
+ <Box key={index}>
+ {line.length > 0 ? (
+ line.map((segment, segIndex) => (
+ <Text key={segIndex} {...segment.props}>
+ {segment.text}
+ </Text>
+ ))
+ ) : (
+ <Text> </Text>
+ )}
+ </Box>
+ ));
+
+ return (
+ <Box flexDirection="column" width={maxWidth} flexShrink={0}>
+ {totalHiddenLines > 0 && overflowDirection === 'top' && (
+ <Text color={Colors.Gray} wrap="truncate">
+ ... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
+ hidden ...
+ </Text>
+ )}
+ {visibleLines}
+ {totalHiddenLines > 0 && overflowDirection === 'bottom' && (
+ <Text color={Colors.Gray} wrap="truncate">
+ ... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
+ hidden ...
+ </Text>
+ )}
+ </Box>
+ );
+};
+
+// Define a type for styled text segments
+interface StyledText {
+ text: string;
+ props: Record<string, unknown>;
+}
+
+/**
+ * Single row of content within the MaxSizedBox.
+ *
+ * A row can contain segments that are not wrapped, followed by segments that
+ * are. This is a minimal implementation that only supports the functionality
+ * needed today.
+ */
+interface Row {
+ noWrapSegments: StyledText[];
+ segments: StyledText[];
+}
+
+/**
+ * Flattens the child elements of MaxSizedBox into an array of `Row` objects.
+ *
+ * This function expects a specific child structure to function correctly:
+ * 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
+ * outer box is primarily for structure and is not directly rendered.
+ * 2. Inside the outer `<Box>`, there should be one or more children. Each of
+ * these children must be a `<Box>` that represents a row.
+ * 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
+ *
+ * The structure should look like this:
+ * <MaxSizedBox>
+ * <Box> // Row 1
+ * <Text>...</Text>
+ * <Text>...</Text>
+ * </Box>
+ * <Box> // Row 2
+ * <Text>...</Text>
+ * </Box>
+ * </MaxSizedBox>
+ *
+ * It is an error for a <Text> child without wrapping to appear after a
+ * <Text> child with wrapping within the same row Box.
+ *
+ * @param element The React node to flatten.
+ * @returns An array of `Row` objects.
+ */
+function visitBoxRow(element: React.ReactNode): Row {
+ if (!React.isValidElement(element) || element.type !== Box) {
+ debugReportError(
+ `All children of MaxSizedBox must be <Box> elements`,
+ element,
+ );
+ return {
+ noWrapSegments: [{ text: '<ERROR>', props: {} }],
+ segments: [],
+ };
+ }
+
+ if (enableDebugLog) {
+ const boxProps = element.props;
+ // Ensure the Box has no props other than the default ones and key.
+ let maxExpectedProps = 4;
+ if (boxProps.children !== undefined) {
+ // Allow the key prop, which is automatically added by React.
+ maxExpectedProps += 1;
+ }
+ if (boxProps.flexDirection !== 'row') {
+ debugReportError(
+ 'MaxSizedBox children must have flexDirection="row".',
+ element,
+ );
+ }
+ if (Object.keys(boxProps).length > maxExpectedProps) {
+ debugReportError(
+ `Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
+ boxProps,
+ ).join(', ')}`,
+ element,
+ );
+ }
+ }
+
+ const row: Row = {
+ noWrapSegments: [],
+ segments: [],
+ };
+
+ let hasSeenWrapped = false;
+
+ function visitRowChild(
+ element: React.ReactNode,
+ parentProps: Record<string, unknown> | undefined,
+ ) {
+ if (element === null) {
+ return;
+ }
+ if (typeof element === 'string' || typeof element === 'number') {
+ const text = String(element);
+ // Ignore empty strings as they don't need to be rendered.
+ if (!text) {
+ return;
+ }
+
+ const segment: StyledText = { text, props: parentProps ?? {} };
+
+ // Check the 'wrap' property from the merged props to decide the segment type.
+ if (parentProps === undefined || parentProps.wrap === 'wrap') {
+ hasSeenWrapped = true;
+ row.segments.push(segment);
+ } else {
+ if (!hasSeenWrapped) {
+ row.noWrapSegments.push(segment);
+ } else {
+ // put in in the wrapped segment as the row is already stuck in wrapped mode.
+ row.segments.push(segment);
+ debugReportError(
+ 'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
+ element,
+ );
+ }
+ }
+ return;
+ }
+
+ if (!React.isValidElement(element)) {
+ debugReportError('Invalid element.', element);
+ return;
+ }
+
+ if (element.type === Fragment) {
+ const fragmentChildren = element.props.children;
+ React.Children.forEach(fragmentChildren, (child) =>
+ visitRowChild(child, parentProps),
+ );
+ return;
+ }
+
+ if (element.type !== Text) {
+ debugReportError(
+ 'Children of a row Box must be <Text> elements.',
+ element,
+ );
+ return;
+ }
+
+ // Merge props from parent <Text> elements. Child props take precedence.
+ const { children, ...currentProps } = element.props;
+ const mergedProps =
+ parentProps === undefined
+ ? currentProps
+ : { ...parentProps, ...currentProps };
+ React.Children.forEach(children, (child) =>
+ visitRowChild(child, mergedProps),
+ );
+ }
+
+ React.Children.forEach(element.props.children, (child) =>
+ visitRowChild(child, undefined),
+ );
+
+ return row;
+}
+
+function layoutInkElementAsStyledText(
+ element: React.ReactElement,
+ maxWidth: number,
+ output: StyledText[][],
+) {
+ const row = visitBoxRow(element);
+ if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
+ // Return a single empty line if there are no segments to display
+ output.push([]);
+ return;
+ }
+
+ const lines: StyledText[][] = [];
+ const nonWrappingContent: StyledText[] = [];
+ let noWrappingWidth = 0;
+
+ // First, lay out the non-wrapping segments
+ row.noWrapSegments.forEach((segment) => {
+ nonWrappingContent.push(segment);
+ noWrappingWidth += stringWidth(segment.text);
+ });
+
+ if (row.segments.length === 0) {
+ // This is a bit of a special case when there are no segments that allow
+ // wrapping. It would be ideal to unify.
+ const lines: StyledText[][] = [];
+ let currentLine: StyledText[] = [];
+ nonWrappingContent.forEach((segment) => {
+ const textLines = segment.text.split('\n');
+ textLines.forEach((text, index) => {
+ if (index > 0) {
+ lines.push(currentLine);
+ currentLine = [];
+ }
+ if (text) {
+ currentLine.push({ text, props: segment.props });
+ }
+ });
+ });
+ if (
+ currentLine.length > 0 ||
+ (nonWrappingContent.length > 0 &&
+ nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
+ ) {
+ lines.push(currentLine);
+ }
+ output.push(...lines);
+ return;
+ }
+
+ const availableWidth = maxWidth - noWrappingWidth;
+
+ if (availableWidth < 1) {
+ // No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
+ output.push(nonWrappingContent);
+ return;
+ }
+
+ // Now, lay out the wrapping segments
+ let wrappingPart: StyledText[] = [];
+ let wrappingPartWidth = 0;
+
+ function addWrappingPartToLines() {
+ if (lines.length === 0) {
+ lines.push([...nonWrappingContent, ...wrappingPart]);
+ } else {
+ if (noWrappingWidth > 0) {
+ lines.push([
+ ...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
+ ...wrappingPart,
+ ]);
+ } else {
+ lines.push(wrappingPart);
+ }
+ }
+ wrappingPart = [];
+ wrappingPartWidth = 0;
+ }
+
+ function addToWrappingPart(text: string, props: Record<string, unknown>) {
+ if (
+ wrappingPart.length > 0 &&
+ wrappingPart[wrappingPart.length - 1].props === props
+ ) {
+ wrappingPart[wrappingPart.length - 1].text += text;
+ } else {
+ wrappingPart.push({ text, props });
+ }
+ }
+
+ row.segments.forEach((segment) => {
+ const linesFromSegment = segment.text.split('\n');
+
+ linesFromSegment.forEach((lineText, lineIndex) => {
+ if (lineIndex > 0) {
+ addWrappingPartToLines();
+ }
+
+ const words = lineText.split(/(\s+)/); // Split by whitespace
+
+ words.forEach((word) => {
+ if (!word) return;
+ const wordWidth = stringWidth(word);
+
+ if (
+ wrappingPartWidth + wordWidth > availableWidth &&
+ wrappingPartWidth > 0
+ ) {
+ addWrappingPartToLines();
+ if (/^\s+$/.test(word)) {
+ return;
+ }
+ }
+
+ if (wordWidth > availableWidth) {
+ // Word is too long, needs to be split across lines
+ const wordAsCodePoints = toCodePoints(word);
+ let remainingWordAsCodePoints = wordAsCodePoints;
+ while (remainingWordAsCodePoints.length > 0) {
+ let splitIndex = 0;
+ let currentSplitWidth = 0;
+ for (const char of remainingWordAsCodePoints) {
+ const charWidth = stringWidth(char);
+ if (
+ wrappingPartWidth + currentSplitWidth + charWidth >
+ availableWidth
+ ) {
+ break;
+ }
+ currentSplitWidth += charWidth;
+ splitIndex++;
+ }
+
+ if (splitIndex > 0) {
+ const part = remainingWordAsCodePoints
+ .slice(0, splitIndex)
+ .join('');
+ addToWrappingPart(part, segment.props);
+ wrappingPartWidth += stringWidth(part);
+ remainingWordAsCodePoints =
+ remainingWordAsCodePoints.slice(splitIndex);
+ }
+
+ if (remainingWordAsCodePoints.length > 0) {
+ addWrappingPartToLines();
+ }
+ }
+ } else {
+ addToWrappingPart(word, segment.props);
+ wrappingPartWidth += wordWidth;
+ }
+ });
+ });
+ // Split omits a trailing newline, so we need to handle it here
+ if (segment.text.endsWith('\n')) {
+ addWrappingPartToLines();
+ }
+ });
+
+ if (wrappingPart.length > 0) {
+ addWrappingPartToLines();
+ }
+ output.push(...lines);
+}
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
index 5430a442..71077f1c 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
@@ -5,7 +5,7 @@
*/
import React from 'react';
-import { Box, Text } from 'ink';
+import { Text } from 'ink';
import SelectInput, {
type ItemProps as InkSelectItemProps,
type IndicatorProps as InkSelectIndicatorProps,
@@ -78,11 +78,12 @@ export function RadioButtonSelect<T>({
isSelected = false,
}: InkSelectIndicatorProps): React.JSX.Element {
return (
- <Box marginRight={1}>
- <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
- {isSelected ? '●' : '○'}
- </Text>
- </Box>
+ <Text
+ color={isSelected ? Colors.AccentGreen : Colors.Foreground}
+ wrap="truncate"
+ >
+ {isSelected ? '● ' : '○ '}
+ </Text>
);
}
@@ -113,14 +114,18 @@ export function RadioButtonSelect<T>({
itemWithThemeProps.themeTypeDisplay
) {
return (
- <Text color={textColor}>
+ <Text color={textColor} wrap="truncate">
{itemWithThemeProps.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
</Text>
);
}
- return <Text color={textColor}>{label}</Text>;
+ return (
+ <Text color={textColor} wrap="truncate">
+ {label}
+ </Text>
+ );
}
initialIndex = initialIndex ?? 0;
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 6b025dd5..93364ebe 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -12,6 +12,7 @@ import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@gemini-cli/core';
+import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
export type Direction =
| 'left'
@@ -69,28 +70,6 @@ function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
-/*
- * -------------------------------------------------------------------------
- * Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
- * code units so that surrogate‑pair emoji count as one "column".)
- * ---------------------------------------------------------------------- */
-
-export function toCodePoints(str: string): string[] {
- // [...str] or Array.from both iterate by UTF‑32 code point, handling
- // surrogate pairs correctly.
- return Array.from(str);
-}
-
-export function cpLen(str: string): number {
- return toCodePoints(str).length;
-}
-
-export function cpSlice(str: string, start: number, end?: number): string {
- // Slice by code‑point indices and re‑join.
- const arr = toCodePoints(str).slice(start, end);
- return arr.join('');
-}
-
/* -------------------------------------------------------------------------
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
* ---------------------------------------------------------------------- */