Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)

This commit is contained in:
Jacob Richman 2025-06-19 20:17:23 +00:00 committed by GitHub
parent 10a83a6395
commit b0bc7c3d99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1353 additions and 248 deletions

View File

@ -115,6 +115,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null); const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null); const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const errorCount = useMemo( const errorCount = useMemo(
() => consoleMessages.filter((msg) => msg.type === 'error').length, () => consoleMessages.filter((msg) => msg.type === 'error').length,
@ -217,7 +218,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const widthFraction = 0.9; const widthFraction = 0.9;
const inputWidth = Math.max( const inputWidth = Math.max(
20, 20,
Math.round(terminalWidth * widthFraction) - 3, Math.floor(terminalWidth * widthFraction) - 3,
); );
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
@ -279,6 +280,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
return; return;
} }
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
} else if (key.ctrl && input === 's') {
setConstrainHeight((prev) => !prev);
} }
}); });
@ -393,10 +396,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
} }
}, [terminalHeight, consoleMessages, showErrorDetails]); }, [terminalHeight, consoleMessages, showErrorDetails]);
const availableTerminalHeight = useMemo(() => { const staticExtraHeight = /* margins and padding */ 3;
const staticExtraHeight = /* margins and padding */ 3; const availableTerminalHeight = useMemo(
return terminalHeight - footerHeight - staticExtraHeight; () => terminalHeight - footerHeight - staticExtraHeight,
}, [terminalHeight, footerHeight]); [terminalHeight, footerHeight],
);
useEffect(() => { useEffect(() => {
if (!pendingHistoryItems.length) { if (!pendingHistoryItems.length) {
@ -445,7 +449,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
{quittingMessages.map((item) => ( {quittingMessages.map((item) => (
<HistoryItemDisplay <HistoryItemDisplay
key={item.id} key={item.id}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
item={item} item={item}
isPending={false} isPending={false}
config={config} config={config}
@ -454,7 +461,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
</Box> </Box>
); );
} }
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
const debugConsoleMaxHeight = Math.max(terminalHeight * 0.2, 5);
// Arbitrary threshold to ensure that items in the static area are large
// enough but not too large to make the terminal hard to use.
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
return ( return (
<StreamingContext.Provider value={streamingState}> <StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" marginBottom={1} width="90%"> <Box flexDirection="column" marginBottom={1} width="90%">
@ -479,7 +490,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
</Box>, </Box>,
...history.map((h) => ( ...history.map((h) => (
<HistoryItemDisplay <HistoryItemDisplay
availableTerminalHeight={availableTerminalHeight} terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
key={h.id} key={h.id}
item={h} item={h}
isPending={false} isPending={false}
@ -494,7 +506,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
{pendingHistoryItems.map((item, i) => ( {pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay <HistoryItemDisplay
key={i} key={i}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
// TODO(taehykim): It seems like references to ids aren't necessary in // TODO(taehykim): It seems like references to ids aren't necessary in
// HistoryItemDisplay. Refactor later. Use a fake id for now. // HistoryItemDisplay. Refactor later. Use a fake id for now.
item={{ ...item, id: 0 }} item={{ ...item, id: 0 }}
@ -534,6 +549,12 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
onSelect={handleThemeSelect} onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight} onHighlight={handleThemeHighlight}
settings={settings} settings={settings}
availableTerminalHeight={
constrainHeight
? terminalHeight - staticExtraHeight
: undefined
}
terminalWidth={mainAreaWidth}
/> />
</Box> </Box>
) : isEditorDialogOpen ? ( ) : isEditorDialogOpen ? (
@ -604,7 +625,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
</Box> </Box>
{showErrorDetails && ( {showErrorDetails && (
<DetailedMessagesDisplay messages={filteredConsoleMessages} /> <DetailedMessagesDisplay
messages={filteredConsoleMessages}
maxHeight={
constrainHeight ? debugConsoleMaxHeight : undefined
}
width={inputWidth}
/>
)} )}
{isInputActive && ( {isInputActive && (

View File

@ -8,20 +8,24 @@ import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { ConsoleMessageItem } from '../types.js'; import { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface DetailedMessagesDisplayProps { interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[]; messages: ConsoleMessageItem[];
maxHeight: number | undefined;
width: number;
// debugMode is not needed here if App.tsx filters debug messages before passing them. // debugMode is not needed here if App.tsx filters debug messages before passing them.
// If DetailedMessagesDisplay should handle filtering, add debugMode prop. // If DetailedMessagesDisplay should handle filtering, add debugMode prop.
} }
export const DetailedMessagesDisplay: React.FC< export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps DetailedMessagesDisplayProps
> = ({ messages }) => { > = ({ messages, maxHeight, width }) => {
if (messages.length === 0) { if (messages.length === 0) {
return null; // Don't render anything if there are no messages return null; // Don't render anything if there are no messages
} }
const borderAndPadding = 4;
return ( return (
<Box <Box
flexDirection="column" flexDirection="column"
@ -29,47 +33,50 @@ export const DetailedMessagesDisplay: React.FC<
borderStyle="round" borderStyle="round"
borderColor={Colors.Gray} borderColor={Colors.Gray}
paddingX={1} paddingX={1}
width={width}
> >
<Box marginBottom={1}> <Box marginBottom={1}>
<Text bold color={Colors.Foreground}> <Text bold color={Colors.Foreground}>
Debug Console <Text color={Colors.Gray}>(ctrl+O to close)</Text> Debug Console <Text color={Colors.Gray}>(ctrl+O to close)</Text>
</Text> </Text>
</Box> </Box>
{messages.map((msg, index) => { <MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
let textColor = Colors.Foreground; {messages.map((msg, index) => {
let icon = '\u2139'; // Information source () let textColor = Colors.Foreground;
let icon = '\u2139'; // Information source ()
switch (msg.type) { switch (msg.type) {
case 'warn': case 'warn':
textColor = Colors.AccentYellow; textColor = Colors.AccentYellow;
icon = '\u26A0'; // Warning sign (⚠) icon = '\u26A0'; // Warning sign (⚠)
break; break;
case 'error': case 'error':
textColor = Colors.AccentRed; textColor = Colors.AccentRed;
icon = '\u2716'; // Heavy multiplication x (✖) icon = '\u2716'; // Heavy multiplication x (✖)
break; break;
case 'debug': case 'debug':
textColor = Colors.Gray; // Or Colors.Gray textColor = Colors.Gray; // Or Colors.Gray
icon = '\u1F50D'; // Left-pointing magnifying glass (????) icon = '\u1F50D'; // Left-pointing magnifying glass (????)
break; break;
case 'log': case 'log':
default: default:
// Default textColor and icon are already set // Default textColor and icon are already set
break; break;
} }
return ( return (
<Box key={index} flexDirection="row"> <Box key={index} flexDirection="row">
<Text color={textColor}>{icon} </Text> <Text color={textColor}>{icon} </Text>
<Text color={textColor} wrap="wrap"> <Text color={textColor} wrap="wrap">
{msg.content} {msg.content}
{msg.count && msg.count > 1 && ( {msg.count && msg.count > 1 && (
<Text color={Colors.Gray}> (x{msg.count})</Text> <Text color={Colors.Gray}> (x{msg.count})</Text>
)} )}
</Text> </Text>
</Box> </Box>
); );
})} })}
</MaxSizedBox>
</Box> </Box>
); );
}; };

View File

@ -20,7 +20,7 @@ describe('<HistoryItemDisplay />', () => {
id: 1, id: 1,
timestamp: 12345, timestamp: 12345,
isPending: false, isPending: false,
availableTerminalHeight: 100, terminalWidth: 80,
}; };
it('renders UserMessage for "user" type', () => { it('renders UserMessage for "user" type', () => {

View File

@ -22,7 +22,8 @@ import { Config } from '@gemini-cli/core';
interface HistoryItemDisplayProps { interface HistoryItemDisplayProps {
item: HistoryItem; item: HistoryItem;
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean; isPending: boolean;
config?: Config; config?: Config;
isFocused?: boolean; isFocused?: boolean;
@ -31,6 +32,7 @@ interface HistoryItemDisplayProps {
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
item, item,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
isPending, isPending,
config, config,
isFocused = true, isFocused = true,
@ -44,6 +46,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
text={item.text} text={item.text}
isPending={isPending} isPending={isPending}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/> />
)} )}
{item.type === 'gemini_content' && ( {item.type === 'gemini_content' && (
@ -51,6 +54,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
text={item.text} text={item.text}
isPending={isPending} isPending={isPending}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/> />
)} )}
{item.type === 'info' && <InfoMessage text={item.text} />} {item.type === 'info' && <InfoMessage text={item.text} />}
@ -78,6 +82,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
toolCalls={item.tools} toolCalls={item.tools}
groupId={item.id} groupId={item.id}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
config={config} config={config}
isFocused={isFocused} isFocused={isFocused}
/> />

View File

@ -9,7 +9,8 @@ import { Text, Box, useInput } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.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 chalk from 'chalk';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import process from 'node:process'; import process from 'node:process';

View File

@ -21,12 +21,16 @@ interface ThemeDialogProps {
onHighlight: (themeName: string | undefined) => void; onHighlight: (themeName: string | undefined) => void;
/** The settings object */ /** The settings object */
settings: LoadedSettings; settings: LoadedSettings;
availableTerminalHeight?: number;
terminalWidth: number;
} }
export function ThemeDialog({ export function ThemeDialog({
onSelect, onSelect,
onHighlight, onHighlight,
settings, settings,
availableTerminalHeight,
terminalWidth,
}: ThemeDialogProps): React.JSX.Element { }: ThemeDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>( const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User, SettingScope.User,
@ -94,6 +98,34 @@ export function ThemeDialog({
: `(Modified in ${otherScope})`; : `(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 ( return (
<Box <Box
borderStyle="round" borderStyle="round"
@ -155,6 +187,8 @@ def fibonacci(n):
a, b = b, a + b a, b = b, a + b
return a`, return a`,
'python', 'python',
availableTerminalHeightCodeBlock,
colorizeCodeWidth,
)} )}
<Box marginTop={1} /> <Box marginTop={1} />
<DiffRenderer <DiffRenderer
@ -165,6 +199,8 @@ def fibonacci(n):
-This line was deleted. -This line was deleted.
+This line was added. +This line was added.
`} `}
availableTerminalHeight={availableTerminalHeightCodeBlock}
terminalWidth={colorizeCodeWidth}
/> />
</Box> </Box>
</Box> </Box>

View File

@ -16,6 +16,9 @@ describe('<DiffRenderer />', () => {
mockColorizeCode.mockClear(); 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', () => { it('should call colorizeCode with correct language for new file with known extension', () => {
const newFileDiffContent = ` const newFileDiffContent = `
diff --git a/test.py b/test.py diff --git a/test.py b/test.py
@ -27,11 +30,17 @@ index 0000000..e69de29
+print("hello world") +print("hello world")
`; `;
render( render(
<DiffRenderer diffContent={newFileDiffContent} filename="test.py" />, <DiffRenderer
diffContent={newFileDiffContent}
filename="test.py"
terminalWidth={80}
/>,
); );
expect(mockColorizeCode).toHaveBeenCalledWith( expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")', 'print("hello world")',
'python', 'python',
undefined,
80,
); );
}); });
@ -46,9 +55,18 @@ index 0000000..e69de29
+some content +some content
`; `;
render( 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', () => { 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 @@ @@ -0,0 +1 @@
+some text content +some text content
`; `;
render(<DiffRenderer diffContent={newFileDiffContent} />); render(
expect(mockColorizeCode).toHaveBeenCalledWith('some text content', null); <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)', () => { 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 <DiffRenderer
diffContent={existingFileDiffContent} diffContent={existingFileDiffContent}
filename="test.txt" filename="test.txt"
terminalWidth={80}
/>, />,
); );
// colorizeCode is used internally by the line-by-line rendering, not for the whole block // 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 +++ b/file.txt
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer diffContent={noChangeDiff} filename="file.txt" />, <DiffRenderer
diffContent={noChangeDiff}
filename="file.txt"
terminalWidth={80}
/>,
); );
expect(lastFrame()).toContain('No changes detected'); expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled(); expect(mockColorizeCode).not.toHaveBeenCalled();
}); });
it('should handle empty diff content', () => { 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(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled(); expect(mockColorizeCode).not.toHaveBeenCalled();
}); });
@ -130,7 +162,11 @@ index 123..456 100644
context line 11 context line 11
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer diffContent={diffWithGap} filename="file.txt" />, <DiffRenderer
diffContent={diffWithGap}
filename="file.txt"
terminalWidth={80}
/>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('═'); // Check for the border character used in the gap expect(output).toContain('═'); // Check for the border character used in the gap
@ -161,7 +197,11 @@ index abc..def 100644
context line 15 context line 15
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer diffContent={diffWithSmallGap} filename="file.txt" />, <DiffRenderer
diffContent={diffWithSmallGap}
filename="file.txt"
terminalWidth={80}
/>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).not.toContain('═'); // Ensure no separator is rendered expect(output).not.toContain('═'); // Ensure no separator is rendered
@ -171,7 +211,7 @@ index abc..def 100644
expect(output).toContain('context line 11'); 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 = ` const diffWithMultipleHunks = `
diff --git a/multi.js b/multi.js diff --git a/multi.js b/multi.js
index 123..789 100644 index 123..789 100644
@ -188,25 +228,56 @@ index 123..789 100644
+const anotherNew = 'test'; +const anotherNew = 'test';
console.log('end of second hunk'); console.log('end of second hunk');
`; `;
const { lastFrame } = render(
<DiffRenderer diffContent={diffWithMultipleHunks} filename="multi.js" />, 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);
},
); );
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('should correctly render a diff with a SVN diff format', () => { 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 \\ No newline at end of file
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer diffContent={newFileDiff} filename="TEST" />, <DiffRenderer
diffContent={newFileDiff}
filename="TEST"
terminalWidth={80}
/>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('1 - const oldVar = 1;'); expect(output).toEqual(`1 - const oldVar = 1;
expect(output).toContain('1 + const newVar = 1;'); 1 + const newVar = 1;
expect(output).toContain('═');
expect(output).toContain("20 - const anotherOld = 'test';"); 20 - const anotherOld = 'test';
expect(output).toContain("20 + const anotherNew = 'test';"); 20 + const anotherNew = 'test';`);
}); });
it('should correctly render a new file with no file extension correctly', () => { 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 \\ No newline at end of file
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer diffContent={newFileDiff} filename="Dockerfile" />, <DiffRenderer
diffContent={newFileDiff}
filename="Dockerfile"
terminalWidth={80}
/>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toEqual(`1 FROM node:14
expect(output).toContain('1 FROM node:14'); 2 RUN npm install
expect(output).toContain('2 RUN npm install'); 3 RUN npm run build`);
expect(output).toContain('3 RUN npm run build');
}); });
}); });

View File

@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import crypto from 'crypto'; import crypto from 'crypto';
import { colorizeCode } from '../../utils/CodeColorizer.js'; import { colorizeCode } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
interface DiffLine { interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other'; type: 'add' | 'del' | 'context' | 'hunk' | 'other';
@ -90,6 +91,8 @@ interface DiffRendererProps {
diffContent: string; diffContent: string;
filename?: string; filename?: string;
tabWidth?: number; tabWidth?: number;
availableTerminalHeight?: number;
terminalWidth: number;
} }
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@ -98,6 +101,8 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
diffContent, diffContent,
filename, filename,
tabWidth = DEFAULT_TAB_WIDTH, tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight,
terminalWidth,
}) => { }) => {
if (!diffContent || typeof diffContent !== 'string') { if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>; return <Text color={Colors.AccentYellow}>No diff content.</Text>;
@ -136,9 +141,20 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
const language = fileExtension const language = fileExtension
? getLanguageFromExtension(fileExtension) ? getLanguageFromExtension(fileExtension)
: null; : null;
renderedOutput = colorizeCode(addedContent, language); renderedOutput = colorizeCode(
addedContent,
language,
availableTerminalHeight,
terminalWidth,
);
} else { } else {
renderedOutput = renderDiffContent(parsedLines, filename, tabWidth); renderedOutput = renderDiffContent(
parsedLines,
filename,
tabWidth,
availableTerminalHeight,
terminalWidth,
);
} }
return renderedOutput; return renderedOutput;
@ -146,8 +162,10 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
const renderDiffContent = ( const renderDiffContent = (
parsedLines: DiffLine[], parsedLines: DiffLine[],
filename?: string, filename: string | undefined,
tabWidth = DEFAULT_TAB_WIDTH, tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
) => { ) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing // 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({ const normalizedLines = parsedLines.map((line) => ({
@ -191,7 +209,11 @@ const renderDiffContent = (
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5; const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
return ( return (
<Box flexDirection="column" key={key}> <MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => { {displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
// Determine the relevant line number for gap calculation based on type // Determine the relevant line number for gap calculation based on type
let relevantLineNumberForGapCalc: number | null = null; let relevantLineNumberForGapCalc: number | null = null;
@ -209,16 +231,9 @@ const renderDiffContent = (
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1 lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
) { ) {
acc.push( acc.push(
<Box <Box key={`gap-${index}`}>
key={`gap-${index}`} <Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
width="100%" </Box>,
borderTop={true}
borderBottom={false}
borderRight={false}
borderLeft={false}
borderStyle="double"
borderColor={Colors.Gray}
></Box>,
); );
} }
@ -271,7 +286,7 @@ const renderDiffContent = (
); );
return acc; return acc;
}, [])} }, [])}
</Box> </MaxSizedBox>
); );
}; };

View File

@ -12,13 +12,15 @@ import { Colors } from '../../colors.js';
interface GeminiMessageProps { interface GeminiMessageProps {
text: string; text: string;
isPending: boolean; isPending: boolean;
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
} }
export const GeminiMessage: React.FC<GeminiMessageProps> = ({ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text, text,
isPending, isPending,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
}) => { }) => {
const prefix = '✦ '; const prefix = '✦ ';
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
@ -33,6 +35,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text={text} text={text}
isPending={isPending} isPending={isPending}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/> />
</Box> </Box>
</Box> </Box>

View File

@ -11,7 +11,8 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps { interface GeminiMessageContentProps {
text: string; text: string;
isPending: boolean; isPending: boolean;
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
} }
/* /*
@ -24,6 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text, text,
isPending, isPending,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
}) => { }) => {
const originalPrefix = '✦ '; const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length; const prefixWidth = originalPrefix.length;
@ -34,6 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text={text} text={text}
isPending={isPending} isPending={isPending}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/> />
</Box> </Box>
); );

View File

@ -20,7 +20,11 @@ describe('ToolConfirmationMessage', () => {
}; };
const { lastFrame } = render( const { lastFrame } = render(
<ToolConfirmationMessage confirmationDetails={confirmationDetails} />, <ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
terminalWidth={80}
/>,
); );
expect(lastFrame()).not.toContain('URLs to fetch:'); expect(lastFrame()).not.toContain('URLs to fetch:');
@ -39,7 +43,11 @@ describe('ToolConfirmationMessage', () => {
}; };
const { lastFrame } = render( const { lastFrame } = render(
<ToolConfirmationMessage confirmationDetails={confirmationDetails} />, <ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
terminalWidth={80}
/>,
); );
expect(lastFrame()).toContain('URLs to fetch:'); expect(lastFrame()).toContain('URLs to fetch:');

View File

@ -19,17 +19,26 @@ import {
RadioButtonSelect, RadioButtonSelect,
RadioSelectItem, RadioSelectItem,
} from '../shared/RadioButtonSelect.js'; } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
export interface ToolConfirmationMessageProps { export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails; confirmationDetails: ToolCallConfirmationDetails;
config?: Config; config?: Config;
isFocused?: boolean; isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
} }
export const ToolConfirmationMessage: React.FC< export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps ToolConfirmationMessageProps
> = ({ confirmationDetails, isFocused = true }) => { > = ({
confirmationDetails,
isFocused = true,
availableTerminalHeight,
terminalWidth,
}) => {
const { onConfirm } = confirmationDetails; const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
useInput((_, key) => { useInput((_, key) => {
if (!isFocused) return; if (!isFocused) return;
@ -47,6 +56,35 @@ export const ToolConfirmationMessage: React.FC<
RadioSelectItem<ToolConfirmationOutcome> 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.type === 'edit') {
if (confirmationDetails.isModifying) { if (confirmationDetails.isModifying) {
return ( 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?`; question = `Apply this change?`;
options.push( options.push(
{ {
@ -91,18 +120,18 @@ export const ToolConfirmationMessage: React.FC<
}, },
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
); );
bodyContent = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
/>
);
} else if (confirmationDetails.type === 'exec') { } else if (confirmationDetails.type === 'exec') {
const executionProps = const executionProps =
confirmationDetails as ToolExecuteConfirmationDetails; confirmationDetails as ToolExecuteConfirmationDetails;
bodyContent = (
<Box flexDirection="column">
<Box paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>{executionProps.command}</Text>
</Box>
</Box>
);
question = `Allow execution?`; question = `Allow execution?`;
options.push( options.push(
{ {
@ -115,12 +144,44 @@ export const ToolConfirmationMessage: React.FC<
}, },
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, { 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') { } else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails; const infoProps = confirmationDetails;
const displayUrls = const displayUrls =
infoProps.urls && infoProps.urls &&
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt); !(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 = ( bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}> <Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text> <Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
@ -134,19 +195,6 @@ export const ToolConfirmationMessage: React.FC<
)} )}
</Box> </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 { } else {
// mcp tool confirmation // mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
@ -177,7 +225,7 @@ export const ToolConfirmationMessage: React.FC<
} }
return ( return (
<Box flexDirection="column" padding={1} minWidth="90%"> <Box flexDirection="column" padding={1} width={childWidth}>
{/* Body Content (Diff Renderer or Command Info) */} {/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */} {/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}> <Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
@ -186,7 +234,7 @@ export const ToolConfirmationMessage: React.FC<
{/* Confirmation Question */} {/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}> <Box marginBottom={1} flexShrink={0}>
<Text>{question}</Text> <Text wrap="truncate">{question}</Text>
</Box> </Box>
{/* Select Input for Options */} {/* Select Input for Options */}

View File

@ -15,7 +15,8 @@ import { Config } from '@gemini-cli/core';
interface ToolGroupMessageProps { interface ToolGroupMessageProps {
groupId: number; groupId: number;
toolCalls: IndividualToolCallDisplay[]; toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
config?: Config; config?: Config;
isFocused?: boolean; isFocused?: boolean;
} }
@ -24,6 +25,7 @@ interface ToolGroupMessageProps {
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls, toolCalls,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
config, config,
isFocused = true, isFocused = true,
}) => { }) => {
@ -33,6 +35,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray; const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
const staticHeight = /* border */ 2 + /* marginBottom */ 1; 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 // 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 // note, after the CTA, this automatically moves over to the next 'confirming' tool
@ -41,6 +46,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
[toolCalls], [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 ( return (
<Box <Box
flexDirection="column" flexDirection="column"
@ -69,7 +91,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
resultDisplay={tool.resultDisplay} resultDisplay={tool.resultDisplay}
status={tool.status} status={tool.status}
confirmationDetails={tool.confirmationDetails} confirmationDetails={tool.confirmationDetails}
availableTerminalHeight={availableTerminalHeight - staticHeight} availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
emphasis={ emphasis={
isConfirming isConfirming
? 'high' ? 'high'
@ -87,6 +110,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
confirmationDetails={tool.confirmationDetails} confirmationDetails={tool.confirmationDetails}
config={config} config={config}
isFocused={isFocused} isFocused={isFocused}
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={innerWidth}
/> />
)} )}
</Box> </Box>

View File

@ -60,7 +60,7 @@ describe('<ToolMessage />', () => {
description: 'A tool for testing', description: 'A tool for testing',
resultDisplay: 'Test result', resultDisplay: 'Test result',
status: ToolCallStatus.Success, status: ToolCallStatus.Success,
availableTerminalHeight: 20, terminalWidth: 80,
confirmationDetails: undefined, confirmationDetails: undefined,
emphasis: 'medium', emphasis: 'medium',
}; };

View File

@ -11,17 +11,18 @@ import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
const STATIC_HEIGHT = 1; const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const STATUS_INDICATOR_WIDTH = 3; const STATUS_INDICATOR_WIDTH = 3;
const MIN_LINES_SHOWN = 2; // show at least this many lines 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 type TextEmphasis = 'high' | 'medium' | 'low';
export interface ToolMessageProps extends IndividualToolCallDisplay { export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
emphasis?: TextEmphasis; emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean; renderOutputAsMarkdown?: boolean;
} }
@ -32,36 +33,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
resultDisplay, resultDisplay,
status, status,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
emphasis = 'medium', emphasis = 'medium',
renderOutputAsMarkdown = true, renderOutputAsMarkdown = true,
}) => { }) => {
const resultIsString = const availableHeight = availableTerminalHeight
typeof resultDisplay === 'string' && resultDisplay.trim().length > 0; ? Math.max(
const lines = React.useMemo( availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
() => (resultIsString ? resultDisplay.split('\n') : []), MIN_LINES_SHOWN + 1, // enforce minimum lines shown
[resultIsString, resultDisplay], )
); : undefined;
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;
}
// Truncate the overall string content if it's too long. const childWidth = terminalWidth - 3; // account for padding.
// 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);
return ( return (
<Box paddingX={1} paddingY={0} flexDirection="column"> <Box paddingX={1} paddingY={0} flexDirection="column">
@ -75,37 +58,32 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
/> />
{emphasis === 'high' && <TrailingIndicator />} {emphasis === 'high' && <TrailingIndicator />}
</Box> </Box>
{displayableResult && ( {resultDisplay && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}> <Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column"> <Box flexDirection="column">
{hiddenLines > 0 && ( {typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
<Box> <Box flexDirection="column">
<Text color={Colors.Gray}> <MarkdownDisplay
... first {hiddenLines} line{hiddenLines === 1 ? '' : 's'}{' '} text={resultDisplay}
hidden ... isPending={false}
</Text> availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box> </Box>
)} )}
{typeof displayableResult === 'string' && {typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
renderOutputAsMarkdown && ( <MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box flexDirection="column"> <Box>
<MarkdownDisplay <Text wrap="wrap">{resultDisplay}</Text>
text={displayableResult}
isPending={false}
availableTerminalHeight={availableTerminalHeight}
/>
</Box> </Box>
)} </MaxSizedBox>
{typeof displayableResult === 'string' && )}
!renderOutputAsMarkdown && ( {typeof resultDisplay !== 'string' && (
<Box flexDirection="column">
<Text>{displayableResult}</Text>
</Box>
)}
{typeof displayableResult !== 'string' && (
<DiffRenderer <DiffRenderer
diffContent={displayableResult.fileDiff} diffContent={resultDisplay.fileDiff}
filename={displayableResult.fileName} filename={resultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/> />
)} )}
</Box> </Box>
@ -193,5 +171,8 @@ const ToolInfo: React.FC<ToolInfo> = ({
}; };
const TrailingIndicator: React.FC = () => ( const TrailingIndicator: React.FC = () => (
<Text color={Colors.Foreground}> </Text> <Text color={Colors.Foreground} wrap="truncate">
{' '}
</Text>
); );

View File

@ -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);
});
});

View File

@ -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);
}

View File

@ -5,7 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Text } from 'ink';
import SelectInput, { import SelectInput, {
type ItemProps as InkSelectItemProps, type ItemProps as InkSelectItemProps,
type IndicatorProps as InkSelectIndicatorProps, type IndicatorProps as InkSelectIndicatorProps,
@ -78,11 +78,12 @@ export function RadioButtonSelect<T>({
isSelected = false, isSelected = false,
}: InkSelectIndicatorProps): React.JSX.Element { }: InkSelectIndicatorProps): React.JSX.Element {
return ( return (
<Box marginRight={1}> <Text
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}> color={isSelected ? Colors.AccentGreen : Colors.Foreground}
{isSelected ? '●' : '○'} wrap="truncate"
</Text> >
</Box> {isSelected ? '● ' : '○ '}
</Text>
); );
} }
@ -113,14 +114,18 @@ export function RadioButtonSelect<T>({
itemWithThemeProps.themeTypeDisplay itemWithThemeProps.themeTypeDisplay
) { ) {
return ( return (
<Text color={textColor}> <Text color={textColor} wrap="truncate">
{itemWithThemeProps.themeNameDisplay}{' '} {itemWithThemeProps.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text> <Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
</Text> </Text>
); );
} }
return <Text color={textColor}>{label}</Text>; return (
<Text color={textColor} wrap="truncate">
{label}
</Text>
);
} }
initialIndex = initialIndex ?? 0; initialIndex = initialIndex ?? 0;

View File

@ -12,6 +12,7 @@ import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { unescapePath } from '@gemini-cli/core'; import { unescapePath } from '@gemini-cli/core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
export type Direction = export type Direction =
| 'left' | 'left'
@ -69,28 +70,6 @@ function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v; return v < min ? min : v > max ? max : v;
} }
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 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 codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
* Debug helper enable verbose logging by setting env var TEXTBUFFER_DEBUG=1 * Debug helper enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */

View File

@ -5,7 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Text } from 'ink'; import { Text, Box } from 'ink';
import { common, createLowlight } from 'lowlight'; import { common, createLowlight } from 'lowlight';
import type { import type {
Root, Root,
@ -16,6 +16,7 @@ import type {
} from 'hast'; } from 'hast';
import { themeManager } from '../themes/theme-manager.js'; import { themeManager } from '../themes/theme-manager.js';
import { Theme } from '../themes/theme.js'; import { Theme } from '../themes/theme.js';
import { MaxSizedBox } from '../components/shared/MaxSizedBox.js';
// Configure themeing and parsing utilities. // Configure themeing and parsing utilities.
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
@ -84,6 +85,8 @@ function renderHastNode(
return null; return null;
} }
const RESERVED_LINES_FOR_TRUNCATION_MESSAGE = 2;
/** /**
* Renders syntax-highlighted code for Ink applications using a selected theme. * Renders syntax-highlighted code for Ink applications using a selected theme.
* *
@ -94,6 +97,8 @@ function renderHastNode(
export function colorizeCode( export function colorizeCode(
code: string, code: string,
language: string | null, language: string | null,
availableHeight?: number,
maxWidth?: number,
): React.ReactNode { ): React.ReactNode {
const codeToHighlight = code.replace(/\n$/, ''); const codeToHighlight = code.replace(/\n$/, '');
const activeTheme = themeManager.getActiveTheme(); const activeTheme = themeManager.getActiveTheme();
@ -101,15 +106,33 @@ export function colorizeCode(
try { try {
// Render the HAST tree using the adapted theme // Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element // Apply the theme's default foreground color to the top-level Text element
const lines = codeToHighlight.split('\n'); let lines = codeToHighlight.split('\n');
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
let hiddenLinesCount = 0;
// Optimizaiton to avoid highlighting lines that cannot possibly be displayed.
if (availableHeight && lines.length > availableHeight) {
const sliceIndex =
lines.length - availableHeight + RESERVED_LINES_FOR_TRUNCATION_MESSAGE;
if (sliceIndex > 0) {
hiddenLinesCount = sliceIndex;
lines = lines.slice(sliceIndex);
}
}
const getHighlightedLines = (line: string) => const getHighlightedLines = (line: string) =>
!language || !lowlight.registered(language) !language || !lowlight.registered(language)
? lowlight.highlightAuto(line) ? lowlight.highlightAuto(line)
: lowlight.highlight(language, line); : lowlight.highlight(language, line);
return ( return (
<Text> <MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
additionalHiddenLinesCount={hiddenLinesCount}
overflowDirection="top"
>
{lines.map((line, index) => { {lines.map((line, index) => {
const renderedNode = renderHastNode( const renderedNode = renderHastNode(
getHighlightedLines(line), getHighlightedLines(line),
@ -119,16 +142,17 @@ export function colorizeCode(
const contentToRender = renderedNode !== null ? renderedNode : line; const contentToRender = renderedNode !== null ? renderedNode : line;
return ( return (
<Text key={index}> <Box key={index}>
<Text color={activeTheme.colors.Gray}> <Text color={activeTheme.colors.Gray}>
{`${String(index + 1).padStart(padWidth, ' ')} `} {`${String(index + 1 + hiddenLinesCount).padStart(padWidth, ' ')} `}
</Text> </Text>
<Text color={activeTheme.defaultColor}>{contentToRender}</Text> <Text color={activeTheme.defaultColor} wrap="wrap">
{index < lines.length - 1 && '\n'} {contentToRender}
</Text> </Text>
</Box>
); );
})} })}
</Text> </MaxSizedBox>
); );
} catch (error) { } catch (error) {
console.error( console.error(
@ -140,17 +164,20 @@ export function colorizeCode(
const lines = codeToHighlight.split('\n'); const lines = codeToHighlight.split('\n');
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
return ( return (
<Text> <MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
overflowDirection="top"
>
{lines.map((line, index) => ( {lines.map((line, index) => (
<Text key={index}> <Box key={index}>
<Text color={activeTheme.defaultColor}> <Text color={activeTheme.defaultColor}>
{`${String(index + 1).padStart(padWidth, ' ')} `} {`${String(index + 1).padStart(padWidth, ' ')} `}
</Text> </Text>
<Text color={activeTheme.colors.Gray}>{line}</Text> <Text color={activeTheme.colors.Gray}>{line}</Text>
{index < lines.length - 1 && '\n'} </Box>
</Text>
))} ))}
</Text> </MaxSizedBox>
); );
} }
} }

View File

@ -12,7 +12,8 @@ import { colorizeCode } from './CodeColorizer.js';
interface MarkdownDisplayProps { interface MarkdownDisplayProps {
text: string; text: string;
isPending: boolean; isPending: boolean;
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
} }
// Constants for Markdown parsing and rendering // Constants for Markdown parsing and rendering
@ -32,6 +33,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
text, text,
isPending, isPending,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
}) => { }) => {
if (!text) return <></>; if (!text) return <></>;
@ -65,6 +67,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lang={codeBlockLang} lang={codeBlockLang}
isPending={isPending} isPending={isPending}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>, />,
); );
inCodeBlock = false; inCodeBlock = false;
@ -186,6 +189,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lang={codeBlockLang} lang={codeBlockLang}
isPending={isPending} isPending={isPending}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>, />,
); );
} }
@ -336,7 +340,8 @@ interface RenderCodeBlockProps {
content: string[]; content: string[];
lang: string | null; lang: string | null;
isPending: boolean; isPending: boolean;
availableTerminalHeight: number; availableTerminalHeight?: number;
terminalWidth: number;
} }
const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
@ -344,15 +349,17 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
lang, lang,
isPending, isPending,
availableTerminalHeight, availableTerminalHeight,
terminalWidth,
}) => { }) => {
const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message
const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
0,
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES,
);
if (isPending) { if (isPending && availableTerminalHeight !== undefined) {
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
0,
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES,
);
if (content.length > MAX_CODE_LINES_WHEN_PENDING) { if (content.length > MAX_CODE_LINES_WHEN_PENDING) {
if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) { if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) {
// Not enough space to even show the message meaningfully // Not enough space to even show the message meaningfully
@ -366,6 +373,8 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
const colorizedTruncatedCode = colorizeCode( const colorizedTruncatedCode = colorizeCode(
truncatedContent.join('\n'), truncatedContent.join('\n'),
lang, lang,
availableTerminalHeight,
terminalWidth - CODE_BLOCK_PADDING * 2,
); );
return ( return (
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}> <Box flexDirection="column" padding={CODE_BLOCK_PADDING}>
@ -377,10 +386,20 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
} }
const fullContent = content.join('\n'); const fullContent = content.join('\n');
const colorizedCode = colorizeCode(fullContent, lang); const colorizedCode = colorizeCode(
fullContent,
lang,
availableTerminalHeight,
terminalWidth - CODE_BLOCK_PADDING * 2,
);
return ( return (
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}> <Box
flexDirection="column"
padding={CODE_BLOCK_PADDING}
width={terminalWidth}
flexShrink={0}
>
{colorizedCode} {colorizedCode}
</Box> </Box>
); );

View File

@ -45,3 +45,25 @@ export function isBinary(
// If no NULL bytes were found in the sample, we assume it's text. // If no NULL bytes were found in the sample, we assume it's text.
return false; return false;
} }
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 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 codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}