mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-09-18 10:48:22 +08:00
fix(ui): guard against negative layout dimensions in border rendering (#29347)
This commit is contained in:
@@ -36,4 +36,31 @@ describe('<ProgressBar />', () => {
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles negative, zero, and fractional widths without throwing', async () => {
|
||||
const { lastFrame: frameNeg } = await renderWithProviders(
|
||||
<ProgressBar value={50} width={-5} />,
|
||||
);
|
||||
expect(frameNeg({ allowEmpty: true })).toBeDefined();
|
||||
|
||||
const { lastFrame: frameZero } = await renderWithProviders(
|
||||
<ProgressBar value={50} width={0} />,
|
||||
);
|
||||
expect(frameZero({ allowEmpty: true })).toBeDefined();
|
||||
|
||||
const { lastFrame: frameFrac } = await renderWithProviders(
|
||||
<ProgressBar value={50} width={10.7} />,
|
||||
);
|
||||
expect(frameFrac()).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([NaN, undefined as unknown as number, Infinity, -Infinity])(
|
||||
'handles non-finite value (%s) without throwing',
|
||||
async (val) => {
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ProgressBar value={val} width={10} />,
|
||||
);
|
||||
expect(lastFrame()).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,9 +19,16 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
width,
|
||||
warningThreshold = 80,
|
||||
}) => {
|
||||
const safeValue = Math.min(Math.max(value, 0), 100);
|
||||
const activeChars = Math.ceil((safeValue / 100) * width);
|
||||
const inactiveChars = width - activeChars;
|
||||
const safeWidth = Math.max(0, Math.floor(width || 0));
|
||||
const safeValue = Math.min(
|
||||
Math.max(Number.isFinite(value) ? value : 0, 0),
|
||||
100,
|
||||
);
|
||||
const activeChars = Math.min(
|
||||
safeWidth,
|
||||
Math.max(0, Math.ceil((safeValue / 100) * safeWidth)),
|
||||
);
|
||||
const inactiveChars = Math.max(0, safeWidth - activeChars);
|
||||
|
||||
let color = theme.status.success;
|
||||
if (safeValue >= 100) {
|
||||
|
||||
@@ -553,5 +553,45 @@ describe('ToolConfirmationQueue', () => {
|
||||
await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([-5, -1, 0, 1, 2, 4])(
|
||||
'renders edit confirmation without throwing when mainAreaWidth is %i',
|
||||
async (width) => {
|
||||
const confirmingTool = {
|
||||
tool: {
|
||||
callId: 'call-narrow-edit',
|
||||
name: 'Edit',
|
||||
description: 'Editing src/main.ts',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
confirmationDetails: {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm edit',
|
||||
fileName: 'main.ts',
|
||||
filePath: '/src/main.ts',
|
||||
fileDiff: '--- a/main.ts\n+++ b/main.ts\n@@ -1 +1 @@\n-old\n+new',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
},
|
||||
},
|
||||
index: 1,
|
||||
total: 1,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationQueue
|
||||
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
|
||||
/>,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState: {
|
||||
mainAreaWidth: width,
|
||||
terminalWidth: width,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toBeDefined();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
const isShell = isShellTool(tool.name);
|
||||
const isEdit = tool.confirmationDetails?.type === 'edit';
|
||||
|
||||
const safeMainAreaWidth = Math.max(0, Math.floor(mainAreaWidth || 0));
|
||||
const safeContentWidth = Math.max(0, safeMainAreaWidth - 4);
|
||||
|
||||
if (isShell || isEdit) {
|
||||
// Use the new simplified layout for Shell and Edit tools
|
||||
const borderColor = theme.border.default;
|
||||
@@ -87,7 +90,8 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={mainAreaWidth}
|
||||
width={safeMainAreaWidth}
|
||||
minWidth={0}
|
||||
flexShrink={0}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
@@ -122,7 +126,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
getPreferredEditor={getPreferredEditor}
|
||||
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
|
||||
terminalWidth={safeContentWidth} // Adjust for parent border/padding
|
||||
availableTerminalHeight={availableContentHeight}
|
||||
toolName={tool.name}
|
||||
isFocused={true}
|
||||
@@ -144,14 +148,19 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={safeMainAreaWidth}
|
||||
minWidth={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
<StickyHeader
|
||||
width={mainAreaWidth}
|
||||
width={safeMainAreaWidth}
|
||||
isFirst={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={false}
|
||||
>
|
||||
<Box flexDirection="column" width={mainAreaWidth - 4}>
|
||||
<Box flexDirection="column" width={safeContentWidth} minWidth={0}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
marginBottom={hideToolIdentity ? 0 : 1}
|
||||
@@ -182,7 +191,8 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
</StickyHeader>
|
||||
|
||||
<Box
|
||||
width={mainAreaWidth}
|
||||
width={safeMainAreaWidth}
|
||||
minWidth={0}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderTop={false}
|
||||
@@ -197,7 +207,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
getPreferredEditor={getPreferredEditor}
|
||||
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
|
||||
terminalWidth={safeContentWidth} // Adjust for parent border/padding
|
||||
availableTerminalHeight={availableContentHeight}
|
||||
toolName={tool.name}
|
||||
isFocused={true}
|
||||
@@ -205,7 +215,8 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
</Box>
|
||||
<Box
|
||||
height={1}
|
||||
width={mainAreaWidth}
|
||||
width={safeMainAreaWidth}
|
||||
minWidth={0}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
|
||||
@@ -613,5 +613,33 @@ describe('DenseToolMessage', () => {
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
});
|
||||
|
||||
it.each([0, -5, NaN])(
|
||||
'handles edge case terminalWidth (%s) without throwing',
|
||||
async (width) => {
|
||||
const diffResult: FileDiff = {
|
||||
fileName: 'test.ts',
|
||||
filePath: '/test.ts',
|
||||
fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
terminalWidth={width}
|
||||
name="edit"
|
||||
description="Editing test.ts"
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>,
|
||||
);
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
expect(renderResult.lastFrame({ allowEmpty: true })).toBeDefined();
|
||||
renderResult.unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,7 +157,7 @@ function getFileOpData(
|
||||
<DiffRenderer
|
||||
diffContent={diff.fileDiff}
|
||||
filename={diff.fileName}
|
||||
terminalWidth={terminalWidth - PAYLOAD_MARGIN_LEFT}
|
||||
terminalWidth={Math.max(0, (terminalWidth || 0) - PAYLOAD_MARGIN_LEFT)}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
disableColor={status === CoreToolCallStatus.Cancelled}
|
||||
/>
|
||||
@@ -404,7 +404,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
return colorizeCode({
|
||||
code: addedContent,
|
||||
language: fileExtension,
|
||||
maxWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
maxWidth: Math.max(0, (terminalWidth || 0) - PAYLOAD_MARGIN_LEFT),
|
||||
settings,
|
||||
disableColor: status === CoreToolCallStatus.Cancelled,
|
||||
returnLines: true,
|
||||
@@ -413,7 +413,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
return renderDiffLines({
|
||||
parsedLines,
|
||||
filename: diff.fileName,
|
||||
terminalWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
terminalWidth: Math.max(0, (terminalWidth || 0) - PAYLOAD_MARGIN_LEFT),
|
||||
disableColor: status === CoreToolCallStatus.Cancelled,
|
||||
});
|
||||
}
|
||||
@@ -488,9 +488,13 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
minWidth={0}
|
||||
maxWidth={Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
(terminalWidth || 0) - PAYLOAD_MARGIN_LEFT,
|
||||
),
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
@@ -499,12 +503,15 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={isFocused}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
width={Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
(terminalWidth || 0) -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -428,6 +428,33 @@ diff --git a/test.txt b/test.txt
|
||||
expect(lastFrame()).not.toContain('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles negative, zero, and very small terminal widths without throwing RangeError', async () => {
|
||||
const diffWithHunks = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
--- a/test.txt
|
||||
+++ b/test.txt
|
||||
@@ -1,2 +1,2 @@
|
||||
-line 1
|
||||
+line 1 modified
|
||||
@@ -10,2 +10,2 @@
|
||||
-line 10
|
||||
+line 10 modified
|
||||
`;
|
||||
for (const terminalWidth of [-5, -1, 0, 1, 2, 4]) {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithHunks}
|
||||
filename="test.txt"
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toBeDefined();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { theme as semanticTheme } from '../../semantic-colors.js';
|
||||
import type { Theme } from '../../themes/theme.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
import { safeRepeat } from '../../utils/borderStyles.js';
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
@@ -108,6 +109,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
disableTruncation = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const safeTerminalWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
|
||||
@@ -161,7 +163,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
availableHeight: disableTruncation
|
||||
? undefined
|
||||
: availableTerminalHeight,
|
||||
maxWidth: terminalWidth,
|
||||
maxWidth: safeTerminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
disableColor,
|
||||
@@ -175,14 +177,14 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
<MaxSizedBox
|
||||
paddingX={paddingX}
|
||||
maxHeight={disableTruncation ? undefined : availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
maxWidth={safeTerminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{renderDiffLines({
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
terminalWidth,
|
||||
terminalWidth: safeTerminalWidth,
|
||||
disableColor,
|
||||
})}
|
||||
</MaxSizedBox>
|
||||
@@ -195,7 +197,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
isNewFileResult,
|
||||
filename,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
safeTerminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
tabWidth,
|
||||
@@ -234,10 +236,13 @@ export const renderDiffLines = ({
|
||||
terminalWidth,
|
||||
disableColor = false,
|
||||
}: RenderDiffLinesOptions): React.ReactNode[] => {
|
||||
const safeTabWidth = Math.max(0, Math.floor(tabWidth || 0));
|
||||
const safeTerminalWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
...line,
|
||||
content: line.content.replace(/\t/g, ' '.repeat(tabWidth)),
|
||||
content: line.content.replace(/\t/g, safeRepeat(' ', safeTabWidth)),
|
||||
}));
|
||||
|
||||
// Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
|
||||
@@ -307,7 +312,7 @@ export const renderDiffLines = ({
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
borderColor={semanticTheme.text.secondary}
|
||||
></Box>
|
||||
</Box>,
|
||||
|
||||
@@ -59,6 +59,8 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
isExpandable,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const safeTerminalWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
|
||||
const { isExpanded: isExpandedInContext } = useToolActions();
|
||||
|
||||
const isExpanded =
|
||||
@@ -99,7 +101,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
if (isExecuting && ptyId) {
|
||||
try {
|
||||
const childWidth = terminalWidth - 4; // account for padding and borders
|
||||
const childWidth = safeTerminalWidth - 4; // account for padding and borders
|
||||
const finalHeight =
|
||||
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
|
||||
|
||||
@@ -119,7 +121,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ptyId, status, terminalWidth, availableHeight]);
|
||||
}, [ptyId, status, safeTerminalWidth, availableHeight]);
|
||||
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const wasFocusedRef = React.useRef(false);
|
||||
@@ -159,7 +161,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
return (
|
||||
<>
|
||||
<StickyHeader
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
isFirst={isFirst}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
@@ -190,7 +192,8 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
|
||||
<Box
|
||||
ref={contentRef}
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
minWidth={0}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
@@ -204,7 +207,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
<ToolResultDisplay
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
terminalWidth={safeTerminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={maxLines}
|
||||
|
||||
@@ -1010,4 +1010,35 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('narrow and negative terminal widths for edit confirmations', () => {
|
||||
const editConfirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'test.txt',
|
||||
filePath: '/test.txt',
|
||||
fileDiff: '--- a/test.txt\n+++ b/test.txt\n@@ -1 +1 @@\n-old\n+new',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
it.each([-5, -1, 0, 1, 2, 4, 10])(
|
||||
'renders edit confirmation without throwing RangeError when terminalWidth is %i',
|
||||
async (terminalWidth) => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-narrow-edit"
|
||||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={terminalWidth}
|
||||
toolName="edit"
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBeDefined();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
toolName,
|
||||
}) => {
|
||||
const safeTerminalWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { confirm, isDiffingEnabled } = useToolActions();
|
||||
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
|
||||
@@ -635,7 +636,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
onCancel={() => {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}}
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
availableHeight={bodyHeight}
|
||||
/>
|
||||
);
|
||||
@@ -668,7 +669,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
onCancel={() => {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}}
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
availableHeight={bodyHeight}
|
||||
/>
|
||||
);
|
||||
@@ -710,7 +711,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
? Math.max(bodyHeight - 2, 2)
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={Math.max(terminalWidth, 1) - 4}
|
||||
terminalWidth={Math.max(0, safeTerminalWidth - 4)}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
@@ -738,7 +739,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{colorizeCode({
|
||||
code: command.trim(),
|
||||
language: 'bash',
|
||||
maxWidth: Math.max(terminalWidth, 1) - 6,
|
||||
maxWidth: Math.max(0, safeTerminalWidth - 6),
|
||||
settings,
|
||||
theme: activeTheme,
|
||||
hideLineNumbers: true,
|
||||
@@ -850,7 +851,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
? Math.max(bodyHeight - 2, 2)
|
||||
: undefined
|
||||
}
|
||||
maxWidth={Math.max(terminalWidth, 1) - 4}
|
||||
maxWidth={Math.max(0, safeTerminalWidth - 4)}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{commandsToDisplay.map((cmd, idx) => (
|
||||
@@ -862,7 +863,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{colorizeCode({
|
||||
code: cmd.trim(),
|
||||
language: 'bash',
|
||||
maxWidth: Math.max(terminalWidth, 1) - 6,
|
||||
maxWidth: Math.max(0, safeTerminalWidth - 6),
|
||||
settings,
|
||||
theme: activeTheme,
|
||||
hideLineNumbers: true,
|
||||
@@ -942,7 +943,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{colorizeCode({
|
||||
code: mcpToolDetailsText || '',
|
||||
language: 'json',
|
||||
maxWidth: Math.max(terminalWidth, 1) - 4,
|
||||
maxWidth: Math.max(0, safeTerminalWidth - 4),
|
||||
settings,
|
||||
theme: activeTheme,
|
||||
hideLineNumbers: true,
|
||||
@@ -969,7 +970,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
confirmationDetails,
|
||||
getOptions,
|
||||
availableBodyContentHeight,
|
||||
terminalWidth,
|
||||
safeTerminalWidth,
|
||||
handleConfirm,
|
||||
deceptiveUrlWarningText,
|
||||
isMcpToolDetailsExpanded,
|
||||
@@ -1018,7 +1019,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
if (confirmationDetails.isModifying) {
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
minWidth={0}
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
justifyContent="space-around"
|
||||
@@ -1062,7 +1064,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
? undefined
|
||||
: availableBodyContentHeight()
|
||||
}
|
||||
maxWidth={terminalWidth}
|
||||
maxWidth={safeTerminalWidth}
|
||||
overflowDirection={bodyOverflowDirection}
|
||||
>
|
||||
{bodyContent}
|
||||
|
||||
@@ -301,7 +301,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
const safeTerminalWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
const contentWidth = Math.max(
|
||||
0,
|
||||
safeTerminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN,
|
||||
);
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
|
||||
// internal errors, plan-mode hidden write/edit), we should not emit standalone
|
||||
@@ -326,7 +330,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
minWidth={0}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
marginBottom={0}
|
||||
>
|
||||
@@ -335,6 +340,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
borderBottomOverride === true && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
minWidth={0}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
@@ -397,6 +403,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
key={group[0].callId}
|
||||
flexDirection="column"
|
||||
width={contentWidth}
|
||||
minWidth={0}
|
||||
>
|
||||
<SubagentGroupDisplay
|
||||
toolCalls={group}
|
||||
@@ -410,6 +417,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{showClosingBorder && (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
minWidth={0}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
@@ -439,7 +447,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
return (
|
||||
<Fragment key={tool.callId}>
|
||||
<Box flexDirection="column" minHeight={1} width={contentWidth}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={contentWidth}
|
||||
minWidth={0}
|
||||
>
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
|
||||
@@ -87,13 +87,15 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
resultDisplay,
|
||||
);
|
||||
|
||||
const safeTerminalWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
|
||||
return (
|
||||
// It is crucial we don't replace this <> with a Box because otherwise the
|
||||
// sticky header inside it would be sticky to that box rather than to the
|
||||
// parent component of this ToolMessage.
|
||||
<>
|
||||
<StickyHeader
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
isFirst={isFirst}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
@@ -119,7 +121,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</StickyHeader>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
width={safeTerminalWidth}
|
||||
minWidth={0}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
@@ -141,7 +144,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
<ToolResultDisplay
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
terminalWidth={safeTerminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={
|
||||
|
||||
@@ -276,18 +276,22 @@ export const McpProgressIndicator: React.FC<McpProgressIndicatorProps> = ({
|
||||
? Math.min(100, Math.round((progress / total) * 100))
|
||||
: null;
|
||||
|
||||
const safeBarWidth = Math.max(0, Math.floor(barWidth || 0));
|
||||
let rawFilled: number;
|
||||
if (total && total > 0) {
|
||||
rawFilled = Math.round((progress / total) * barWidth);
|
||||
rawFilled = Math.round((progress / total) * safeBarWidth);
|
||||
} else {
|
||||
rawFilled = Math.floor(progress) % (barWidth + 1);
|
||||
rawFilled = Math.floor(progress) % (safeBarWidth + 1);
|
||||
}
|
||||
|
||||
const filled = Math.max(
|
||||
0,
|
||||
Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth),
|
||||
Math.min(
|
||||
Number.isFinite(rawFilled) ? Math.floor(rawFilled) : 0,
|
||||
safeBarWidth,
|
||||
),
|
||||
);
|
||||
const empty = Math.max(0, barWidth - filled);
|
||||
const empty = Math.max(0, safeBarWidth - filled);
|
||||
const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,4 +94,26 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles zero and negative terminal widths without throwing', async () => {
|
||||
const { lastFrame: frameZero, unmount: unmountZero } =
|
||||
await renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
<Text>Content</Text>
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: 0 },
|
||||
);
|
||||
expect(frameZero()).toBeDefined();
|
||||
unmountZero();
|
||||
|
||||
const { lastFrame: frameNeg, unmount: unmountNeg } =
|
||||
await renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
<Text>Content</Text>
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: -5 },
|
||||
);
|
||||
expect(frameNeg()).toBeDefined();
|
||||
unmountNeg();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { interpolateColor, resolveColor } from '../../themes/color-utils.js';
|
||||
import { supportsTrueColor } from '@google/gemini-cli-core';
|
||||
import { safeRepeat } from '../../utils/borderStyles.js';
|
||||
|
||||
export interface HalfLinePaddedBoxProps {
|
||||
/**
|
||||
@@ -70,9 +71,11 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
|
||||
const noTrueColor = !supportsTrueColor();
|
||||
|
||||
const safeWidth = Math.max(0, Math.floor(terminalWidth || 0));
|
||||
|
||||
if (noTrueColor) {
|
||||
return (
|
||||
<Box width={terminalWidth} backgroundColor={backgroundColor} paddingY={1}>
|
||||
<Box width={safeWidth} backgroundColor={backgroundColor} paddingY={1}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
@@ -80,25 +83,25 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
width={safeWidth}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
minHeight={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
|
||||
<Box width={safeWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{safeRepeat('▄', safeWidth)}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
width={safeWidth}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
|
||||
<Box width={safeWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{safeRepeat('▀', safeWidth)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
|
||||
import { stripUnsafeCharacters } from './textUtils.js';
|
||||
import { safeRepeat } from './borderStyles.js';
|
||||
|
||||
interface TableRendererProps {
|
||||
headers: string[];
|
||||
@@ -236,7 +237,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
) : (
|
||||
<Text>{content.text}</Text>
|
||||
)}
|
||||
{' '.repeat(paddingNeeded)}
|
||||
{safeRepeat(' ', paddingNeeded)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -251,7 +252,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
|
||||
const char = chars[type];
|
||||
const borderParts = adjustedWidths.map((w) =>
|
||||
char.horizontal.repeat(Math.max(0, w || 0)),
|
||||
safeRepeat(char.horizontal, w),
|
||||
);
|
||||
const border = char.left + borderParts.join(char.middle) + char.right;
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import { isShellTool } from '../components/messages/ToolShared.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
export {
|
||||
renderBorder,
|
||||
safeRepeat,
|
||||
renderNodeToOutput,
|
||||
} from './renderBorder.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
renderBorder,
|
||||
renderNodeToOutput,
|
||||
safeRepeat,
|
||||
} from './renderBorder.js';
|
||||
|
||||
describe('renderBorder', () => {
|
||||
it('returns empty string and does not throw for negative counts (-1, -5, -10)', () => {
|
||||
expect(renderBorder(-1)).toBe('');
|
||||
expect(renderBorder(-5)).toBe('');
|
||||
expect(renderBorder(-10)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for zero count (0)', () => {
|
||||
expect(renderBorder(0)).toBe('');
|
||||
});
|
||||
|
||||
it('floors fractional counts safely without errors (1.7, 0.5, 1.5, 0.7)', () => {
|
||||
expect(renderBorder(1.7)).toBe('─');
|
||||
expect(renderBorder(0.5)).toBe('');
|
||||
expect(renderBorder(1.5)).toBe('─');
|
||||
expect(renderBorder(0.7)).toBe('');
|
||||
expect(renderBorder(3.9)).toBe('───');
|
||||
});
|
||||
|
||||
it('repeats default border character for positive integer counts', () => {
|
||||
expect(renderBorder(1)).toBe('─');
|
||||
expect(renderBorder(3)).toBe('───');
|
||||
expect(renderBorder(5)).toBe('─────');
|
||||
});
|
||||
|
||||
it('supports custom border characters', () => {
|
||||
expect(renderBorder(3, '=')).toBe('===');
|
||||
expect(renderBorder(4, '═')).toBe('════');
|
||||
expect(renderBorder(-1, '=')).toBe('');
|
||||
expect(renderBorder(0, '=')).toBe('');
|
||||
expect(renderBorder(2.8, '*')).toBe('**');
|
||||
});
|
||||
|
||||
it('handles non-finite values safely without throwing', () => {
|
||||
expect(renderBorder(NaN)).toBe('');
|
||||
expect(renderBorder(Infinity)).toBe('');
|
||||
expect(renderBorder(-Infinity)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderNodeToOutput', () => {
|
||||
it('returns empty string and does not throw for negative counts (-1, -5)', () => {
|
||||
expect(renderNodeToOutput(-1)).toBe('');
|
||||
expect(renderNodeToOutput(-5)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for zero count (0)', () => {
|
||||
expect(renderNodeToOutput(0)).toBe('');
|
||||
});
|
||||
|
||||
it('floors fractional counts safely without errors (1.7, 0.5)', () => {
|
||||
expect(renderNodeToOutput(1.7)).toBe('─');
|
||||
expect(renderNodeToOutput(0.5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeRepeat', () => {
|
||||
it('returns empty string for negative counts', () => {
|
||||
expect(safeRepeat('a', -1)).toBe('');
|
||||
expect(safeRepeat('x', -5)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for zero count', () => {
|
||||
expect(safeRepeat('a', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('floors fractional counts safely', () => {
|
||||
expect(safeRepeat('a', 1.7)).toBe('a');
|
||||
expect(safeRepeat('a', 0.5)).toBe('');
|
||||
expect(safeRepeat('abc', 2.9)).toBe('abcabc');
|
||||
});
|
||||
|
||||
it('handles non-finite numbers safely', () => {
|
||||
expect(safeRepeat('a', NaN)).toBe('');
|
||||
expect(safeRepeat('a', Infinity)).toBe('');
|
||||
expect(safeRepeat('a', -Infinity)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely repeats a character or string count times, guarding against negative,
|
||||
* NaN, non-finite, or fractional counts.
|
||||
*
|
||||
* @param char The string to repeat.
|
||||
* @param count The number of times to repeat the string.
|
||||
* @returns The repeated string, or '' if count <= 0 or non-finite.
|
||||
*/
|
||||
export function safeRepeat(char: string, count: number): string {
|
||||
if (!Number.isFinite(count)) {
|
||||
return '';
|
||||
}
|
||||
const safeCount = Math.max(0, Math.floor(count));
|
||||
if (safeCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return char.repeat(safeCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely renders a horizontal border string of a given width using the specified border character.
|
||||
* Guards against negative, NaN, non-finite, or fractional width values.
|
||||
*
|
||||
* @param width The desired width/count of the border.
|
||||
* @param borderChar The character to repeat for the border (default: '─').
|
||||
* @returns The repeated border character string, or '' if width <= 0 or non-finite.
|
||||
*/
|
||||
export function renderBorder(width: number, borderChar: string = '─'): string {
|
||||
if (!Number.isFinite(width)) {
|
||||
return '';
|
||||
}
|
||||
const safeCount = Math.max(0, Math.floor(width));
|
||||
if (safeCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return borderChar.repeat(safeCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for renderBorder to support renderNodeToOutput pipeline verification.
|
||||
*/
|
||||
export const renderNodeToOutput = renderBorder;
|
||||
Reference in New Issue
Block a user