fix(ui): guard against negative layout dimensions in border rendering (#29347)

This commit is contained in:
Diego Godinez
2026-09-17 18:07:22 +00:00
committed by GitHub
parent 8fd119f75f
commit e09d9d727e
20 changed files with 441 additions and 63 deletions
@@ -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();
},
);
});
+10 -3
View File
@@ -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>
);
+3 -2
View File
@@ -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('');
});
});
+48
View File
@@ -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;