diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index a5317b30..a9c5f0e7 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -115,6 +115,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); const ctrlDTimerRef = useRef(null); + const [constrainHeight, setConstrainHeight] = useState(true); const errorCount = useMemo( () => consoleMessages.filter((msg) => msg.type === 'error').length, @@ -217,7 +218,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const widthFraction = 0.9; const inputWidth = Math.max( 20, - Math.round(terminalWidth * widthFraction) - 3, + Math.floor(terminalWidth * widthFraction) - 3, ); const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); @@ -279,6 +280,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { return; } 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]); - const availableTerminalHeight = useMemo(() => { - const staticExtraHeight = /* margins and padding */ 3; - return terminalHeight - footerHeight - staticExtraHeight; - }, [terminalHeight, footerHeight]); + const staticExtraHeight = /* margins and padding */ 3; + const availableTerminalHeight = useMemo( + () => terminalHeight - footerHeight - staticExtraHeight, + [terminalHeight, footerHeight], + ); useEffect(() => { if (!pendingHistoryItems.length) { @@ -445,7 +449,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { {quittingMessages.map((item) => ( { ); } - + 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 ( @@ -479,7 +490,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { , ...history.map((h) => ( { {pendingHistoryItems.map((item, i) => ( { onSelect={handleThemeSelect} onHighlight={handleThemeHighlight} settings={settings} + availableTerminalHeight={ + constrainHeight + ? terminalHeight - staticExtraHeight + : undefined + } + terminalWidth={mainAreaWidth} /> ) : isEditorDialogOpen ? ( @@ -604,7 +625,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { {showErrorDetails && ( - + )} {isInputActive && ( 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 ( Debug Console (ctrl+O to close) - {messages.map((msg, index) => { - let textColor = Colors.Foreground; - let icon = '\u2139'; // Information source (ℹ) + + {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 ( - - {icon} - - {msg.content} - {msg.count && msg.count > 1 && ( - (x{msg.count}) - )} - - - ); - })} + return ( + + {icon} + + {msg.content} + {msg.count && msg.count > 1 && ( + (x{msg.count}) + )} + + + ); + })} + ); }; 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('', () => { 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 = ({ item, availableTerminalHeight, + terminalWidth, isPending, config, isFocused = true, @@ -44,6 +46,7 @@ export const HistoryItemDisplay: React.FC = ({ text={item.text} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} /> )} {item.type === 'gemini_content' && ( @@ -51,6 +54,7 @@ export const HistoryItemDisplay: React.FC = ({ text={item.text} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} /> )} {item.type === 'info' && } @@ -78,6 +82,7 @@ export const HistoryItemDisplay: React.FC = ({ 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.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 ( 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('', () => { 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( - , + , ); expect(mockColorizeCode).toHaveBeenCalledWith( 'print("hello world")', 'python', + undefined, + 80, ); }); @@ -46,9 +55,18 @@ index 0000000..e69de29 +some content `; render( - , + , + ); + 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(); - expect(mockColorizeCode).toHaveBeenCalledWith('some text content', null); + render( + , + ); + 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 , ); // 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( - , + , ); expect(lastFrame()).toContain('No changes detected'); expect(mockColorizeCode).not.toHaveBeenCalled(); }); it('should handle empty diff content', () => { - const { lastFrame } = render(); + const { lastFrame } = render( + , + ); expect(lastFrame()).toContain('No diff content'); expect(mockColorizeCode).not.toHaveBeenCalled(); }); @@ -130,7 +162,11 @@ index 123..456 100644 context line 11 `; const { lastFrame } = render( - , + , ); 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( - , + , ); 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( - , + + 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( + , + ); + 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', () => { @@ -226,15 +297,19 @@ fileDiff Index: file.txt \\ No newline at end of file `; const { lastFrame } = render( - , + , ); 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( - , + , ); 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 = ({ diffContent, filename, tabWidth = DEFAULT_TAB_WIDTH, + availableTerminalHeight, + terminalWidth, }) => { if (!diffContent || typeof diffContent !== 'string') { return No diff content.; @@ -136,9 +141,20 @@ export const DiffRenderer: React.FC = ({ 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 = ({ 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 ( - + {displayableLines.reduce((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( - , + + {'═'.repeat(terminalWidth)} + , ); } @@ -271,7 +286,7 @@ const renderDiffContent = ( ); return acc; }, [])} - + ); }; 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 = ({ text, isPending, availableTerminalHeight, + terminalWidth, }) => { const prefix = '✦ '; const prefixWidth = prefix.length; @@ -33,6 +35,7 @@ export const GeminiMessage: React.FC = ({ text={text} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} /> 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 = ({ text, isPending, availableTerminalHeight, + terminalWidth, }) => { const originalPrefix = '✦ '; const prefixWidth = originalPrefix.length; @@ -34,6 +36,7 @@ export const GeminiMessageContent: React.FC = ({ text={text} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} /> ); 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( - , + , ); expect(lastFrame()).not.toContain('URLs to fetch:'); @@ -39,7 +43,11 @@ describe('ToolConfirmationMessage', () => { }; const { lastFrame } = render( - , + , ); 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 >(); + // 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 = ( - - ); - question = `Apply this change?`; options.push( { @@ -91,18 +120,18 @@ export const ToolConfirmationMessage: React.FC< }, { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, ); + bodyContent = ( + + ); } else if (confirmationDetails.type === 'exec') { const executionProps = confirmationDetails as ToolExecuteConfirmationDetails; - bodyContent = ( - - - {executionProps.command} - - - ); - 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 = ( + + + + + {executionProps.command} + + + + + ); } 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 = ( {infoProps.prompt} @@ -134,19 +195,6 @@ export const ToolConfirmationMessage: React.FC< )} ); - - 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 ( - + {/* Body Content (Diff Renderer or Command Info) */} {/* No separate context display here anymore for edits */} @@ -186,7 +234,7 @@ export const ToolConfirmationMessage: React.FC< {/* Confirmation Question */} - {question} + {question} {/* 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 = ({ toolCalls, availableTerminalHeight, + terminalWidth, config, isFocused = true, }) => { @@ -33,6 +35,9 @@ export const ToolGroupMessage: React.FC = ({ 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 = ({ [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 ( = ({ 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 = ({ confirmationDetails={tool.confirmationDetails} config={config} isFocused={isFocused} + availableTerminalHeight={ + availableTerminalHeightPerToolMessage + } + terminalWidth={innerWidth} /> )} 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('', () => { 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 = ({ 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 ( @@ -75,37 +58,32 @@ export const ToolMessage: React.FC = ({ /> {emphasis === 'high' && } - {displayableResult && ( + {resultDisplay && ( - {hiddenLines > 0 && ( - - - ... first {hiddenLines} line{hiddenLines === 1 ? '' : 's'}{' '} - hidden ... - + {typeof resultDisplay === 'string' && renderOutputAsMarkdown && ( + + )} - {typeof displayableResult === 'string' && - renderOutputAsMarkdown && ( - - + {typeof resultDisplay === 'string' && !renderOutputAsMarkdown && ( + + + {resultDisplay} - )} - {typeof displayableResult === 'string' && - !renderOutputAsMarkdown && ( - - {displayableResult} - - )} - {typeof displayableResult !== 'string' && ( + + )} + {typeof resultDisplay !== 'string' && ( )} @@ -193,5 +171,8 @@ const ToolInfo: React.FC = ({ }; const TrailingIndicator: React.FC = () => ( - + + {' '} + ← + ); 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('', () => { + it('renders children without truncation when they fit', () => { + const { lastFrame } = render( + + + Hello, World! + + , + ); + expect(lastFrame()).equals('Hello, World!'); + }); + + it('hides lines when content exceeds maxHeight', () => { + const { lastFrame } = render( + + + Line 1 + + + Line 2 + + + Line 3 + + , + ); + 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( + + + Line 1 + + + Line 2 + + + Line 3 + + , + ); + expect(lastFrame()).equals(`Line 1 +... last 2 lines hidden ...`); + }); + + it('wraps text that exceeds maxWidth', () => { + const { lastFrame } = render( + + + This is a long line of text + + , + ); + + 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( + + + Example + + + No Wrap: + {multilineText} + + + Longer No Wrap: + This part will wrap around. + + , + ); + + 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( + + + Supercalifragilisticexpialidocious + + , + ); + + expect(lastFrame()).equals(`... … +istic +expia +lidoc +ious`); + }); + + it('does not truncate when maxHeight is undefined', () => { + const { lastFrame } = render( + + + Line 1 + + + Line 2 + + , + ); + expect(lastFrame()).equals(`Line 1 +Line 2`); + }); + + it('shows plural "lines" when more than one line is hidden', () => { + const { lastFrame } = render( + + + Line 1 + + + Line 2 + + + Line 3 + + , + ); + 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( + + + Line 1 + + + Line 2 + + + Line 3 + + , + ); + expect(lastFrame()).equals(`Line 1 +... last 2 lines hidden ...`); + }); + + it('renders an empty box for empty children', () => { + const { lastFrame } = render( + , + ); + // 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( + + + 你好世界 + + , + ); + + // "你好" 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( + + + 🐶🐶🐶🐶🐶 + + , + ); + + // 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( + + + Line 1 + + + Line 2 + + + Line 3 + + , + ); + // 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( + + <> + + Line 1 from Fragment + + + Line 2 from Fragment + + + + Line 3 direct child + + , + ); + 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( + + + {THIRTY_LINES} + + , + ); + + 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( + + + {THIRTY_LINES} + + , + ); + + 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 = ''; + try { + const elementWithSource = element as { + _source?: { fileName?: string; lineNumber?: number }; + }; + const fileName = elementWithSource._source?.fileName; + const lineNumber = elementWithSource._source?.lineNumber; + sourceMessage = fileName ? `${fileName}:${lineNumber}` : ''; + } 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 `` elements.** Each `` represents a + * single row of content. + * 2. **Row `` elements must contain only `` elements.** These + * `` 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 `` elements are + * ignored. In debug mode, runtime checks will report errors for any + * unsupported properties. + * - **Text Wrapping:** Within a single row, `` elements with no wrapping + * (e.g., headers, labels) must appear before any `` elements that wrap. + * - **Element Types:** Runtime checks will warn if unsupported element types + * are used as children. + * + * @example + * + * + * This is the first line. + * + * + * Non-wrapping Header: + * This is the rest of the line which will wrap if it's too long. + * + * + * + * Line 3 with nested styled text inside of it. + * + * + * + */ +export const MaxSizedBox: React.FC = ({ + 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 ( + + {children} + + ); + } + + 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 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) => ( + + {line.length > 0 ? ( + line.map((segment, segIndex) => ( + + {segment.text} + + )) + ) : ( + + )} + + )); + + return ( + + {totalHiddenLines > 0 && overflowDirection === 'top' && ( + + ... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '} + hidden ... + + )} + {visibleLines} + {totalHiddenLines > 0 && overflowDirection === 'bottom' && ( + + ... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '} + hidden ... + + )} + + ); +}; + +// Define a type for styled text segments +interface StyledText { + text: string; + props: Record; +} + +/** + * 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 ``. This + * outer box is primarily for structure and is not directly rendered. + * 2. Inside the outer ``, there should be one or more children. Each of + * these children must be a `` that represents a row. + * 3. Inside each "row" ``, the children must be `` components. + * + * The structure should look like this: + * + * // Row 1 + * ... + * ... + * + * // Row 2 + * ... + * + * + * + * It is an error for a child without wrapping to appear after a + * 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 elements`, + element, + ); + return { + noWrapSegments: [{ text: '', 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 | 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 elements.', + element, + ); + return; + } + + // Merge props from parent 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) { + 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({ isSelected = false, }: InkSelectIndicatorProps): React.JSX.Element { return ( - - - {isSelected ? '●' : '○'} - - + + {isSelected ? '● ' : '○ '} + ); } @@ -113,14 +114,18 @@ export function RadioButtonSelect({ itemWithThemeProps.themeTypeDisplay ) { return ( - + {itemWithThemeProps.themeNameDisplay}{' '} {itemWithThemeProps.themeTypeDisplay} ); } - return {label}; + return ( + + {label} + + ); } 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 * ---------------------------------------------------------------------- */ diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index f3e7e8eb..f96e6c9a 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { Text } from 'ink'; +import { Text, Box } from 'ink'; import { common, createLowlight } from 'lowlight'; import type { Root, @@ -16,6 +16,7 @@ import type { } from 'hast'; import { themeManager } from '../themes/theme-manager.js'; import { Theme } from '../themes/theme.js'; +import { MaxSizedBox } from '../components/shared/MaxSizedBox.js'; // Configure themeing and parsing utilities. const lowlight = createLowlight(common); @@ -84,6 +85,8 @@ function renderHastNode( return null; } +const RESERVED_LINES_FOR_TRUNCATION_MESSAGE = 2; + /** * Renders syntax-highlighted code for Ink applications using a selected theme. * @@ -94,6 +97,8 @@ function renderHastNode( export function colorizeCode( code: string, language: string | null, + availableHeight?: number, + maxWidth?: number, ): React.ReactNode { const codeToHighlight = code.replace(/\n$/, ''); const activeTheme = themeManager.getActiveTheme(); @@ -101,15 +106,33 @@ export function colorizeCode( try { // Render the HAST tree using the adapted theme // 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 + + 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) => !language || !lowlight.registered(language) ? lowlight.highlightAuto(line) : lowlight.highlight(language, line); return ( - + {lines.map((line, index) => { const renderedNode = renderHastNode( getHighlightedLines(line), @@ -119,16 +142,17 @@ export function colorizeCode( const contentToRender = renderedNode !== null ? renderedNode : line; return ( - + - {`${String(index + 1).padStart(padWidth, ' ')} `} + {`${String(index + 1 + hiddenLinesCount).padStart(padWidth, ' ')} `} - {contentToRender} - {index < lines.length - 1 && '\n'} - + + {contentToRender} + + ); })} - + ); } catch (error) { console.error( @@ -140,17 +164,20 @@ export function colorizeCode( const lines = codeToHighlight.split('\n'); const padWidth = String(lines.length).length; // Calculate padding width based on number of lines return ( - + {lines.map((line, index) => ( - + {`${String(index + 1).padStart(padWidth, ' ')} `} {line} - {index < lines.length - 1 && '\n'} - + ))} - + ); } } diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 1eda45d3..d78360b5 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -12,7 +12,8 @@ import { colorizeCode } from './CodeColorizer.js'; interface MarkdownDisplayProps { text: string; isPending: boolean; - availableTerminalHeight: number; + availableTerminalHeight?: number; + terminalWidth: number; } // Constants for Markdown parsing and rendering @@ -32,6 +33,7 @@ const MarkdownDisplayInternal: React.FC = ({ text, isPending, availableTerminalHeight, + terminalWidth, }) => { if (!text) return <>; @@ -65,6 +67,7 @@ const MarkdownDisplayInternal: React.FC = ({ lang={codeBlockLang} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} />, ); inCodeBlock = false; @@ -186,6 +189,7 @@ const MarkdownDisplayInternal: React.FC = ({ lang={codeBlockLang} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} />, ); } @@ -336,7 +340,8 @@ interface RenderCodeBlockProps { content: string[]; lang: string | null; isPending: boolean; - availableTerminalHeight: number; + availableTerminalHeight?: number; + terminalWidth: number; } const RenderCodeBlockInternal: React.FC = ({ @@ -344,15 +349,17 @@ const RenderCodeBlockInternal: React.FC = ({ lang, isPending, availableTerminalHeight, + terminalWidth, }) => { 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 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 (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) { // Not enough space to even show the message meaningfully @@ -366,6 +373,8 @@ const RenderCodeBlockInternal: React.FC = ({ const colorizedTruncatedCode = colorizeCode( truncatedContent.join('\n'), lang, + availableTerminalHeight, + terminalWidth - CODE_BLOCK_PADDING * 2, ); return ( @@ -377,10 +386,20 @@ const RenderCodeBlockInternal: React.FC = ({ } const fullContent = content.join('\n'); - const colorizedCode = colorizeCode(fullContent, lang); + const colorizedCode = colorizeCode( + fullContent, + lang, + availableTerminalHeight, + terminalWidth - CODE_BLOCK_PADDING * 2, + ); return ( - + {colorizedCode} ); diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 35e4c4a2..f7006047 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -45,3 +45,25 @@ export function isBinary( // If no NULL bytes were found in the sample, we assume it's text. return false; } + +/* + * ------------------------------------------------------------------------- + * 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(''); +}