summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/messages
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-06-19 20:17:23 +0000
committerGitHub <[email protected]>2025-06-19 13:17:23 -0700
commitb0bc7c3d996d25c9fefdfbcba3ca19fa46ad199f (patch)
treec2d89d14b8dade1daf51f835969d9b0e79d4df30 /packages/cli/src/ui/components/messages
parent10a83a6395b70f21b01da99d0992c78d0354a8dd (diff)
Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)
Diffstat (limited to 'packages/cli/src/ui/components/messages')
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.test.tsx156
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.tsx45
-rw-r--r--packages/cli/src/ui/components/messages/GeminiMessage.tsx5
-rw-r--r--packages/cli/src/ui/components/messages/GeminiMessageContent.tsx5
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx12
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx114
-rw-r--r--packages/cli/src/ui/components/messages/ToolGroupMessage.tsx31
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.test.tsx2
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.tsx89
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>
);