mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)
This commit is contained in:
@@ -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" />,
|
||||
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
expected: `1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
expected: `... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
expected: `... first 10 lines hidden ...
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height, expected }) => {
|
||||
const { lastFrame } = render(
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// Check for content from the first hunk
|
||||
expect(output).toContain("1 console.log('first hunk');");
|
||||
expect(output).toContain('2 - const oldVar = 1;');
|
||||
expect(output).toContain('2 + const newVar = 1;');
|
||||
expect(output).toContain("3 console.log('end of first hunk');");
|
||||
|
||||
// Check for the gap indicator between hunks
|
||||
expect(output).toContain('═');
|
||||
|
||||
// Check for content from the second hunk
|
||||
expect(output).toContain("20 console.log('second hunk');");
|
||||
expect(output).toContain("21 - const anotherOld = 'test';");
|
||||
expect(output).toContain("21 + const anotherNew = 'test';");
|
||||
expect(output).toContain("22 console.log('end of second hunk');");
|
||||
});
|
||||
|
||||
it('should correctly render a diff with a SVN diff format', () => {
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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:');
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user