diff options
| author | Jacob Richman <[email protected]> | 2025-06-19 20:17:23 +0000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-06-19 13:17:23 -0700 |
| commit | b0bc7c3d996d25c9fefdfbcba3ca19fa46ad199f (patch) | |
| tree | c2d89d14b8dade1daf51f835969d9b0e79d4df30 /packages/cli/src/ui/components/messages | |
| parent | 10a83a6395b70f21b01da99d0992c78d0354a8dd (diff) | |
Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)
Diffstat (limited to 'packages/cli/src/ui/components/messages')
9 files changed, 311 insertions, 148 deletions
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 8c2bba43..52152f55 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -16,6 +16,9 @@ describe('<DiffRenderer />', () => { mockColorizeCode.mockClear(); }); + const sanitizeOutput = (output: string | undefined, terminalWidth: number) => + output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth)); + it('should call colorizeCode with correct language for new file with known extension', () => { const newFileDiffContent = ` diff --git a/test.py b/test.py @@ -27,11 +30,17 @@ index 0000000..e69de29 +print("hello world") `; render( - <DiffRenderer diffContent={newFileDiffContent} filename="test.py" />, + <DiffRenderer + diffContent={newFileDiffContent} + filename="test.py" + terminalWidth={80} + />, ); expect(mockColorizeCode).toHaveBeenCalledWith( 'print("hello world")', 'python', + undefined, + 80, ); }); @@ -46,9 +55,18 @@ index 0000000..e69de29 +some content `; render( - <DiffRenderer diffContent={newFileDiffContent} filename="test.unknown" />, + <DiffRenderer + diffContent={newFileDiffContent} + filename="test.unknown" + terminalWidth={80} + />, + ); + expect(mockColorizeCode).toHaveBeenCalledWith( + 'some content', + null, + undefined, + 80, ); - expect(mockColorizeCode).toHaveBeenCalledWith('some content', null); }); it('should call colorizeCode with null language for new file if no filename is provided', () => { @@ -61,8 +79,15 @@ index 0000000..e69de29 @@ -0,0 +1 @@ +some text content `; - render(<DiffRenderer diffContent={newFileDiffContent} />); - expect(mockColorizeCode).toHaveBeenCalledWith('some text content', null); + render( + <DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />, + ); + expect(mockColorizeCode).toHaveBeenCalledWith( + 'some text content', + null, + undefined, + 80, + ); }); it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => { @@ -79,6 +104,7 @@ index 0000001..0000002 100644 <DiffRenderer diffContent={existingFileDiffContent} filename="test.txt" + terminalWidth={80} />, ); // colorizeCode is used internally by the line-by-line rendering, not for the whole block @@ -103,14 +129,20 @@ index 1234567..1234567 100644 +++ b/file.txt `; const { lastFrame } = render( - <DiffRenderer diffContent={noChangeDiff} filename="file.txt" />, + <DiffRenderer + diffContent={noChangeDiff} + filename="file.txt" + terminalWidth={80} + />, ); expect(lastFrame()).toContain('No changes detected'); expect(mockColorizeCode).not.toHaveBeenCalled(); }); it('should handle empty diff content', () => { - const { lastFrame } = render(<DiffRenderer diffContent="" />); + const { lastFrame } = render( + <DiffRenderer diffContent="" terminalWidth={80} />, + ); expect(lastFrame()).toContain('No diff content'); expect(mockColorizeCode).not.toHaveBeenCalled(); }); @@ -130,7 +162,11 @@ index 123..456 100644 context line 11 `; const { lastFrame } = render( - <DiffRenderer diffContent={diffWithGap} filename="file.txt" />, + <DiffRenderer + diffContent={diffWithGap} + filename="file.txt" + terminalWidth={80} + />, ); const output = lastFrame(); expect(output).toContain('═'); // Check for the border character used in the gap @@ -161,7 +197,11 @@ index abc..def 100644 context line 15 `; const { lastFrame } = render( - <DiffRenderer diffContent={diffWithSmallGap} filename="file.txt" />, + <DiffRenderer + diffContent={diffWithSmallGap} + filename="file.txt" + terminalWidth={80} + />, ); const output = lastFrame(); expect(output).not.toContain('═'); // Ensure no separator is rendered @@ -171,7 +211,7 @@ index abc..def 100644 expect(output).toContain('context line 11'); }); - it('should correctly render a diff with multiple hunks and a gap indicator', () => { + describe('should correctly render a diff with multiple hunks and a gap indicator', () => { const diffWithMultipleHunks = ` diff --git a/multi.js b/multi.js index 123..789 100644 @@ -188,25 +228,56 @@ index 123..789 100644 +const anotherNew = 'test'; console.log('end of second hunk'); `; - const { lastFrame } = render( - <DiffRenderer diffContent={diffWithMultipleHunks} filename="multi.js" />, - ); - const output = lastFrame(); - - // Check for content from the first hunk - expect(output).toContain("1 console.log('first hunk');"); - expect(output).toContain('2 - const oldVar = 1;'); - expect(output).toContain('2 + const newVar = 1;'); - expect(output).toContain("3 console.log('end of first hunk');"); - // Check for the gap indicator between hunks - expect(output).toContain('═'); - - // Check for content from the second hunk - expect(output).toContain("20 console.log('second hunk');"); - expect(output).toContain("21 - const anotherOld = 'test';"); - expect(output).toContain("21 + const anotherNew = 'test';"); - expect(output).toContain("22 console.log('end of second hunk');"); + it.each([ + { + terminalWidth: 80, + height: undefined, + expected: `1 console.log('first hunk'); +2 - const oldVar = 1; +2 + const newVar = 1; +3 console.log('end of first hunk'); +════════════════════════════════════════════════════════════════════════════════ +20 console.log('second hunk'); +21 - const anotherOld = 'test'; +21 + const anotherNew = 'test'; +22 console.log('end of second hunk');`, + }, + { + terminalWidth: 80, + height: 6, + expected: `... first 4 lines hidden ... +════════════════════════════════════════════════════════════════════════════════ +20 console.log('second hunk'); +21 - const anotherOld = 'test'; +21 + const anotherNew = 'test'; +22 console.log('end of second hunk');`, + }, + { + terminalWidth: 30, + height: 6, + expected: `... first 10 lines hidden ... + 'test'; +21 + const anotherNew = + 'test'; +22 console.log('end of + second hunk');`, + }, + ])( + 'with terminalWidth $terminalWidth and height $height', + ({ terminalWidth, height, expected }) => { + const { lastFrame } = render( + <DiffRenderer + diffContent={diffWithMultipleHunks} + filename="multi.js" + terminalWidth={terminalWidth} + availableTerminalHeight={height} + />, + ); + const output = lastFrame(); + expect(sanitizeOutput(output, terminalWidth)).toEqual(expected); + }, + ); }); it('should correctly render a diff with a SVN diff format', () => { @@ -226,15 +297,19 @@ fileDiff Index: file.txt \\ No newline at end of file `; const { lastFrame } = render( - <DiffRenderer diffContent={newFileDiff} filename="TEST" />, + <DiffRenderer + diffContent={newFileDiff} + filename="TEST" + terminalWidth={80} + />, ); const output = lastFrame(); - expect(output).toContain('1 - const oldVar = 1;'); - expect(output).toContain('1 + const newVar = 1;'); - expect(output).toContain('═'); - expect(output).toContain("20 - const anotherOld = 'test';"); - expect(output).toContain("20 + const anotherNew = 'test';"); + expect(output).toEqual(`1 - const oldVar = 1; +1 + const newVar = 1; +════════════════════════════════════════════════════════════════════════════════ +20 - const anotherOld = 'test'; +20 + const anotherNew = 'test';`); }); it('should correctly render a new file with no file extension correctly', () => { @@ -250,12 +325,15 @@ fileDiff Index: Dockerfile \\ No newline at end of file `; const { lastFrame } = render( - <DiffRenderer diffContent={newFileDiff} filename="Dockerfile" />, + <DiffRenderer + diffContent={newFileDiff} + filename="Dockerfile" + terminalWidth={80} + />, ); const output = lastFrame(); - - expect(output).toContain('1 FROM node:14'); - expect(output).toContain('2 RUN npm install'); - expect(output).toContain('3 RUN npm run build'); + expect(output).toEqual(`1 FROM node:14 +2 RUN npm install +3 RUN npm run build`); }); }); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index 0b35e32d..25fb293e 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { Colors } from '../../colors.js'; import crypto from 'crypto'; import { colorizeCode } from '../../utils/CodeColorizer.js'; +import { MaxSizedBox } from '../shared/MaxSizedBox.js'; interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; @@ -90,6 +91,8 @@ interface DiffRendererProps { diffContent: string; filename?: string; tabWidth?: number; + availableTerminalHeight?: number; + terminalWidth: number; } const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization @@ -98,6 +101,8 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, filename, tabWidth = DEFAULT_TAB_WIDTH, + availableTerminalHeight, + terminalWidth, }) => { if (!diffContent || typeof diffContent !== 'string') { return <Text color={Colors.AccentYellow}>No diff content.</Text>; @@ -136,9 +141,20 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({ const language = fileExtension ? getLanguageFromExtension(fileExtension) : null; - renderedOutput = colorizeCode(addedContent, language); + renderedOutput = colorizeCode( + addedContent, + language, + availableTerminalHeight, + terminalWidth, + ); } else { - renderedOutput = renderDiffContent(parsedLines, filename, tabWidth); + renderedOutput = renderDiffContent( + parsedLines, + filename, + tabWidth, + availableTerminalHeight, + terminalWidth, + ); } return renderedOutput; @@ -146,8 +162,10 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({ const renderDiffContent = ( parsedLines: DiffLine[], - filename?: string, + filename: string | undefined, tabWidth = DEFAULT_TAB_WIDTH, + availableTerminalHeight: number | undefined, + terminalWidth: number, ) => { // 1. Normalize whitespace (replace tabs with spaces) *before* further processing const normalizedLines = parsedLines.map((line) => ({ @@ -191,7 +209,11 @@ const renderDiffContent = ( const MAX_CONTEXT_LINES_WITHOUT_GAP = 5; return ( - <Box flexDirection="column" key={key}> + <MaxSizedBox + maxHeight={availableTerminalHeight} + maxWidth={terminalWidth} + key={key} + > {displayableLines.reduce<React.ReactNode[]>((acc, line, index) => { // Determine the relevant line number for gap calculation based on type let relevantLineNumberForGapCalc: number | null = null; @@ -209,16 +231,9 @@ const renderDiffContent = ( lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1 ) { acc.push( - <Box - key={`gap-${index}`} - width="100%" - borderTop={true} - borderBottom={false} - borderRight={false} - borderLeft={false} - borderStyle="double" - borderColor={Colors.Gray} - ></Box>, + <Box key={`gap-${index}`}> + <Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text> + </Box>, ); } @@ -271,7 +286,7 @@ const renderDiffContent = ( ); return acc; }, [])} - </Box> + </MaxSizedBox> ); }; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index df8d0a87..9863acd6 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -12,13 +12,15 @@ import { Colors } from '../../colors.js'; interface GeminiMessageProps { text: string; isPending: boolean; - availableTerminalHeight: number; + availableTerminalHeight?: number; + terminalWidth: number; } export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text, isPending, availableTerminalHeight, + terminalWidth, }) => { const prefix = '✦ '; const prefixWidth = prefix.length; @@ -33,6 +35,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text={text} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} /> </Box> </Box> diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx index da6e468a..b5f01599 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -11,7 +11,8 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; interface GeminiMessageContentProps { text: string; isPending: boolean; - availableTerminalHeight: number; + availableTerminalHeight?: number; + terminalWidth: number; } /* @@ -24,6 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({ text, isPending, availableTerminalHeight, + terminalWidth, }) => { const originalPrefix = '✦ '; const prefixWidth = originalPrefix.length; @@ -34,6 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({ text={text} isPending={isPending} availableTerminalHeight={availableTerminalHeight} + terminalWidth={terminalWidth} /> </Box> ); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index a2d76247..6af03d54 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -20,7 +20,11 @@ describe('ToolConfirmationMessage', () => { }; const { lastFrame } = render( - <ToolConfirmationMessage confirmationDetails={confirmationDetails} />, + <ToolConfirmationMessage + confirmationDetails={confirmationDetails} + availableTerminalHeight={30} + terminalWidth={80} + />, ); expect(lastFrame()).not.toContain('URLs to fetch:'); @@ -39,7 +43,11 @@ describe('ToolConfirmationMessage', () => { }; const { lastFrame } = render( - <ToolConfirmationMessage confirmationDetails={confirmationDetails} />, + <ToolConfirmationMessage + confirmationDetails={confirmationDetails} + availableTerminalHeight={30} + terminalWidth={80} + />, ); expect(lastFrame()).toContain('URLs to fetch:'); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index e1e53ff6..4f2c31d3 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -19,17 +19,26 @@ import { RadioButtonSelect, RadioSelectItem, } from '../shared/RadioButtonSelect.js'; +import { MaxSizedBox } from '../shared/MaxSizedBox.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; config?: Config; isFocused?: boolean; + availableTerminalHeight?: number; + terminalWidth: number; } export const ToolConfirmationMessage: React.FC< ToolConfirmationMessageProps -> = ({ confirmationDetails, isFocused = true }) => { +> = ({ + confirmationDetails, + isFocused = true, + availableTerminalHeight, + terminalWidth, +}) => { const { onConfirm } = confirmationDetails; + const childWidth = terminalWidth - 2; // 2 for padding useInput((_, key) => { if (!isFocused) return; @@ -47,6 +56,35 @@ export const ToolConfirmationMessage: React.FC< RadioSelectItem<ToolConfirmationOutcome> >(); + // Body content is now the DiffRenderer, passing filename to it + // The bordered box is removed from here and handled within DiffRenderer + + function availableBodyContentHeight() { + if (options.length === 0) { + // This should not happen in practice as options are always added before this is called. + throw new Error('Options not provided for confirmation message'); + } + + if (availableTerminalHeight === undefined) { + return undefined; + } + + // Calculate the vertical space (in lines) consumed by UI elements + // surrounding the main body content. + const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom). + const MARGIN_BODY_BOTTOM = 1; // margin on the body container. + const HEIGHT_QUESTION = 1; // The question text is one line. + const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container. + const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line. + + const surroundingElementsHeight = + PADDING_OUTER_Y + + MARGIN_BODY_BOTTOM + + HEIGHT_QUESTION + + MARGIN_QUESTION_BOTTOM + + HEIGHT_OPTIONS; + return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); + } if (confirmationDetails.type === 'edit') { if (confirmationDetails.isModifying) { return ( @@ -66,15 +104,6 @@ export const ToolConfirmationMessage: React.FC< ); } - // Body content is now the DiffRenderer, passing filename to it - // The bordered box is removed from here and handled within DiffRenderer - bodyContent = ( - <DiffRenderer - diffContent={confirmationDetails.fileDiff} - filename={confirmationDetails.fileName} - /> - ); - question = `Apply this change?`; options.push( { @@ -91,18 +120,18 @@ export const ToolConfirmationMessage: React.FC< }, { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, ); + bodyContent = ( + <DiffRenderer + diffContent={confirmationDetails.fileDiff} + filename={confirmationDetails.fileName} + availableTerminalHeight={availableBodyContentHeight()} + terminalWidth={childWidth} + /> + ); } else if (confirmationDetails.type === 'exec') { const executionProps = confirmationDetails as ToolExecuteConfirmationDetails; - bodyContent = ( - <Box flexDirection="column"> - <Box paddingX={1} marginLeft={1}> - <Text color={Colors.AccentCyan}>{executionProps.command}</Text> - </Box> - </Box> - ); - question = `Allow execution?`; options.push( { @@ -115,12 +144,44 @@ export const ToolConfirmationMessage: React.FC< }, { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, ); + + let bodyContentHeight = availableBodyContentHeight(); + if (bodyContentHeight !== undefined) { + bodyContentHeight -= 2; // Account for padding; + } + bodyContent = ( + <Box flexDirection="column"> + <Box paddingX={1} marginLeft={1}> + <MaxSizedBox + maxHeight={bodyContentHeight} + maxWidth={Math.max(childWidth - 4, 1)} + > + <Box> + <Text color={Colors.AccentCyan}>{executionProps.command}</Text> + </Box> + </MaxSizedBox> + </Box> + </Box> + ); } else if (confirmationDetails.type === 'info') { const infoProps = confirmationDetails; const displayUrls = infoProps.urls && !(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt); + question = `Do you want to proceed?`; + options.push( + { + label: 'Yes, allow once', + value: ToolConfirmationOutcome.ProceedOnce, + }, + { + label: 'Yes, allow always', + value: ToolConfirmationOutcome.ProceedAlways, + }, + { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, + ); + bodyContent = ( <Box flexDirection="column" paddingX={1} marginLeft={1}> <Text color={Colors.AccentCyan}>{infoProps.prompt}</Text> @@ -134,19 +195,6 @@ export const ToolConfirmationMessage: React.FC< )} </Box> ); - - question = `Do you want to proceed?`; - options.push( - { - label: 'Yes, allow once', - value: ToolConfirmationOutcome.ProceedOnce, - }, - { - label: 'Yes, allow always', - value: ToolConfirmationOutcome.ProceedAlways, - }, - { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, - ); } else { // mcp tool confirmation const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; @@ -177,7 +225,7 @@ export const ToolConfirmationMessage: React.FC< } return ( - <Box flexDirection="column" padding={1} minWidth="90%"> + <Box flexDirection="column" padding={1} width={childWidth}> {/* Body Content (Diff Renderer or Command Info) */} {/* No separate context display here anymore for edits */} <Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}> @@ -186,7 +234,7 @@ export const ToolConfirmationMessage: React.FC< {/* Confirmation Question */} <Box marginBottom={1} flexShrink={0}> - <Text>{question}</Text> + <Text wrap="truncate">{question}</Text> </Box> {/* Select Input for Options */} diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 8ce40893..445a157c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,7 +15,8 @@ import { Config } from '@gemini-cli/core'; interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; - availableTerminalHeight: number; + availableTerminalHeight?: number; + terminalWidth: number; config?: Config; isFocused?: boolean; } @@ -24,6 +25,7 @@ interface ToolGroupMessageProps { export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ toolCalls, availableTerminalHeight, + terminalWidth, config, isFocused = true, }) => { @@ -33,6 +35,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray; const staticHeight = /* border */ 2 + /* marginBottom */ 1; + // This is a bit of a magic number, but it accounts for the border and + // marginLeft. + const innerWidth = terminalWidth - 4; // only prompt for tool approval on the first 'confirming' tool in the list // note, after the CTA, this automatically moves over to the next 'confirming' tool @@ -41,6 +46,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ [toolCalls], ); + let countToolCallsWithResults = 0; + for (const tool of toolCalls) { + if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { + countToolCallsWithResults++; + } + } + const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults; + const availableTerminalHeightPerToolMessage = availableTerminalHeight + ? Math.max( + Math.floor( + (availableTerminalHeight - staticHeight - countOneLineToolCalls) / + Math.max(1, countToolCallsWithResults), + ), + 1, + ) + : undefined; + return ( <Box flexDirection="column" @@ -69,7 +91,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ resultDisplay={tool.resultDisplay} status={tool.status} confirmationDetails={tool.confirmationDetails} - availableTerminalHeight={availableTerminalHeight - staticHeight} + availableTerminalHeight={availableTerminalHeightPerToolMessage} + terminalWidth={innerWidth} emphasis={ isConfirming ? 'high' @@ -87,6 +110,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ confirmationDetails={tool.confirmationDetails} config={config} isFocused={isFocused} + availableTerminalHeight={ + availableTerminalHeightPerToolMessage + } + terminalWidth={innerWidth} /> )} </Box> diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 2b96f18a..74e6709a 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -60,7 +60,7 @@ describe('<ToolMessage />', () => { description: 'A tool for testing', resultDisplay: 'Test result', status: ToolCallStatus.Success, - availableTerminalHeight: 20, + terminalWidth: 80, confirmationDetails: undefined, emphasis: 'medium', }; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 230f651c..dd23381e 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -11,17 +11,18 @@ import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { MaxSizedBox } from '../shared/MaxSizedBox.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. const STATUS_INDICATOR_WIDTH = 3; const MIN_LINES_SHOWN = 2; // show at least this many lines -const MIN_LINES_HIDDEN = 3; // hide at least this many lines (or don't hide any) export type TextEmphasis = 'high' | 'medium' | 'low'; export interface ToolMessageProps extends IndividualToolCallDisplay { - availableTerminalHeight: number; + availableTerminalHeight?: number; + terminalWidth: number; emphasis?: TextEmphasis; renderOutputAsMarkdown?: boolean; } @@ -32,36 +33,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ resultDisplay, status, availableTerminalHeight, + terminalWidth, emphasis = 'medium', renderOutputAsMarkdown = true, }) => { - const resultIsString = - typeof resultDisplay === 'string' && resultDisplay.trim().length > 0; - const lines = React.useMemo( - () => (resultIsString ? resultDisplay.split('\n') : []), - [resultIsString, resultDisplay], - ); - let contentHeightEstimate = Math.max( - availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, - MIN_LINES_SHOWN + 1, // enforce minimum lines shown - ); - // enforce minimum lines hidden (don't hide any otherwise) - if (lines.length - contentHeightEstimate < MIN_LINES_HIDDEN) { - contentHeightEstimate = lines.length; - } + const availableHeight = availableTerminalHeight + ? Math.max( + availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, + MIN_LINES_SHOWN + 1, // enforce minimum lines shown + ) + : undefined; - // Truncate the overall string content if it's too long. - // MarkdownRenderer will handle specific truncation for code blocks within this content. - // Estimate available height for this specific tool message content area - // This is a rough estimate; ideally, we'd have a more precise measurement. - const displayableResult = React.useMemo( - () => - resultIsString - ? lines.slice(-contentHeightEstimate).join('\n') - : resultDisplay, - [lines, resultIsString, contentHeightEstimate, resultDisplay], - ); - const hiddenLines = Math.max(0, lines.length - contentHeightEstimate); + const childWidth = terminalWidth - 3; // account for padding. return ( <Box paddingX={1} paddingY={0} flexDirection="column"> @@ -75,37 +58,32 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ /> {emphasis === 'high' && <TrailingIndicator />} </Box> - {displayableResult && ( + {resultDisplay && ( <Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}> <Box flexDirection="column"> - {hiddenLines > 0 && ( - <Box> - <Text color={Colors.Gray}> - ... first {hiddenLines} line{hiddenLines === 1 ? '' : 's'}{' '} - hidden ... - </Text> + {typeof resultDisplay === 'string' && renderOutputAsMarkdown && ( + <Box flexDirection="column"> + <MarkdownDisplay + text={resultDisplay} + isPending={false} + availableTerminalHeight={availableHeight} + terminalWidth={childWidth} + /> </Box> )} - {typeof displayableResult === 'string' && - renderOutputAsMarkdown && ( - <Box flexDirection="column"> - <MarkdownDisplay - text={displayableResult} - isPending={false} - availableTerminalHeight={availableTerminalHeight} - /> + {typeof resultDisplay === 'string' && !renderOutputAsMarkdown && ( + <MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}> + <Box> + <Text wrap="wrap">{resultDisplay}</Text> </Box> - )} - {typeof displayableResult === 'string' && - !renderOutputAsMarkdown && ( - <Box flexDirection="column"> - <Text>{displayableResult}</Text> - </Box> - )} - {typeof displayableResult !== 'string' && ( + </MaxSizedBox> + )} + {typeof resultDisplay !== 'string' && ( <DiffRenderer - diffContent={displayableResult.fileDiff} - filename={displayableResult.fileName} + diffContent={resultDisplay.fileDiff} + filename={resultDisplay.fileName} + availableTerminalHeight={availableHeight} + terminalWidth={childWidth} /> )} </Box> @@ -193,5 +171,8 @@ const ToolInfo: React.FC<ToolInfo> = ({ }; const TrailingIndicator: React.FC = () => ( - <Text color={Colors.Foreground}> ←</Text> + <Text color={Colors.Foreground} wrap="truncate"> + {' '} + ← + </Text> ); |
