Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)
This commit is contained in:
parent
10a83a6395
commit
b0bc7c3d99
|
@ -115,6 +115,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
|
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
|
||||||
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||||
|
|
||||||
const errorCount = useMemo(
|
const errorCount = useMemo(
|
||||||
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
||||||
|
@ -217,7 +218,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
const widthFraction = 0.9;
|
const widthFraction = 0.9;
|
||||||
const inputWidth = Math.max(
|
const inputWidth = Math.max(
|
||||||
20,
|
20,
|
||||||
Math.round(terminalWidth * widthFraction) - 3,
|
Math.floor(terminalWidth * widthFraction) - 3,
|
||||||
);
|
);
|
||||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||||
|
|
||||||
|
@ -279,6 +280,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||||
|
} else if (key.ctrl && input === 's') {
|
||||||
|
setConstrainHeight((prev) => !prev);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -393,10 +396,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
}
|
}
|
||||||
}, [terminalHeight, consoleMessages, showErrorDetails]);
|
}, [terminalHeight, consoleMessages, showErrorDetails]);
|
||||||
|
|
||||||
const availableTerminalHeight = useMemo(() => {
|
|
||||||
const staticExtraHeight = /* margins and padding */ 3;
|
const staticExtraHeight = /* margins and padding */ 3;
|
||||||
return terminalHeight - footerHeight - staticExtraHeight;
|
const availableTerminalHeight = useMemo(
|
||||||
}, [terminalHeight, footerHeight]);
|
() => terminalHeight - footerHeight - staticExtraHeight,
|
||||||
|
[terminalHeight, footerHeight],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingHistoryItems.length) {
|
if (!pendingHistoryItems.length) {
|
||||||
|
@ -445,7 +449,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
{quittingMessages.map((item) => (
|
{quittingMessages.map((item) => (
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
key={item.id}
|
key={item.id}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={
|
||||||
|
constrainHeight ? availableTerminalHeight : undefined
|
||||||
|
}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
item={item}
|
item={item}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
config={config}
|
config={config}
|
||||||
|
@ -454,7 +461,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
|
||||||
|
const debugConsoleMaxHeight = Math.max(terminalHeight * 0.2, 5);
|
||||||
|
// Arbitrary threshold to ensure that items in the static area are large
|
||||||
|
// enough but not too large to make the terminal hard to use.
|
||||||
|
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||||
return (
|
return (
|
||||||
<StreamingContext.Provider value={streamingState}>
|
<StreamingContext.Provider value={streamingState}>
|
||||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||||
|
@ -479,7 +490,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
</Box>,
|
</Box>,
|
||||||
...history.map((h) => (
|
...history.map((h) => (
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
terminalWidth={mainAreaWidth}
|
||||||
|
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||||
key={h.id}
|
key={h.id}
|
||||||
item={h}
|
item={h}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
|
@ -494,7 +506,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
{pendingHistoryItems.map((item, i) => (
|
{pendingHistoryItems.map((item, i) => (
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
key={i}
|
key={i}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={
|
||||||
|
constrainHeight ? availableTerminalHeight : undefined
|
||||||
|
}
|
||||||
|
terminalWidth={mainAreaWidth}
|
||||||
// TODO(taehykim): It seems like references to ids aren't necessary in
|
// TODO(taehykim): It seems like references to ids aren't necessary in
|
||||||
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
||||||
item={{ ...item, id: 0 }}
|
item={{ ...item, id: 0 }}
|
||||||
|
@ -534,6 +549,12 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
onSelect={handleThemeSelect}
|
onSelect={handleThemeSelect}
|
||||||
onHighlight={handleThemeHighlight}
|
onHighlight={handleThemeHighlight}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
availableTerminalHeight={
|
||||||
|
constrainHeight
|
||||||
|
? terminalHeight - staticExtraHeight
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
terminalWidth={mainAreaWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : isEditorDialogOpen ? (
|
) : isEditorDialogOpen ? (
|
||||||
|
@ -604,7 +625,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showErrorDetails && (
|
{showErrorDetails && (
|
||||||
<DetailedMessagesDisplay messages={filteredConsoleMessages} />
|
<DetailedMessagesDisplay
|
||||||
|
messages={filteredConsoleMessages}
|
||||||
|
maxHeight={
|
||||||
|
constrainHeight ? debugConsoleMaxHeight : undefined
|
||||||
|
}
|
||||||
|
width={inputWidth}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isInputActive && (
|
{isInputActive && (
|
||||||
|
|
|
@ -8,20 +8,24 @@ import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { ConsoleMessageItem } from '../types.js';
|
import { ConsoleMessageItem } from '../types.js';
|
||||||
|
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||||
|
|
||||||
interface DetailedMessagesDisplayProps {
|
interface DetailedMessagesDisplayProps {
|
||||||
messages: ConsoleMessageItem[];
|
messages: ConsoleMessageItem[];
|
||||||
|
maxHeight: number | undefined;
|
||||||
|
width: number;
|
||||||
// debugMode is not needed here if App.tsx filters debug messages before passing them.
|
// debugMode is not needed here if App.tsx filters debug messages before passing them.
|
||||||
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
|
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailedMessagesDisplay: React.FC<
|
export const DetailedMessagesDisplay: React.FC<
|
||||||
DetailedMessagesDisplayProps
|
DetailedMessagesDisplayProps
|
||||||
> = ({ messages }) => {
|
> = ({ messages, maxHeight, width }) => {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return null; // Don't render anything if there are no messages
|
return null; // Don't render anything if there are no messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderAndPadding = 4;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
|
@ -29,12 +33,14 @@ export const DetailedMessagesDisplay: React.FC<
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor={Colors.Gray}
|
borderColor={Colors.Gray}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
|
width={width}
|
||||||
>
|
>
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text bold color={Colors.Foreground}>
|
<Text bold color={Colors.Foreground}>
|
||||||
Debug Console <Text color={Colors.Gray}>(ctrl+O to close)</Text>
|
Debug Console <Text color={Colors.Gray}>(ctrl+O to close)</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
|
||||||
{messages.map((msg, index) => {
|
{messages.map((msg, index) => {
|
||||||
let textColor = Colors.Foreground;
|
let textColor = Colors.Foreground;
|
||||||
let icon = '\u2139'; // Information source (ℹ)
|
let icon = '\u2139'; // Information source (ℹ)
|
||||||
|
@ -70,6 +76,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</MaxSizedBox>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,7 +20,7 @@ describe('<HistoryItemDisplay />', () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
timestamp: 12345,
|
timestamp: 12345,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
availableTerminalHeight: 100,
|
terminalWidth: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders UserMessage for "user" type', () => {
|
it('renders UserMessage for "user" type', () => {
|
||||||
|
|
|
@ -22,7 +22,8 @@ import { Config } from '@gemini-cli/core';
|
||||||
|
|
||||||
interface HistoryItemDisplayProps {
|
interface HistoryItemDisplayProps {
|
||||||
item: HistoryItem;
|
item: HistoryItem;
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
|
@ -31,6 +32,7 @@ interface HistoryItemDisplayProps {
|
||||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
item,
|
item,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
isPending,
|
isPending,
|
||||||
config,
|
config,
|
||||||
isFocused = true,
|
isFocused = true,
|
||||||
|
@ -44,6 +46,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
text={item.text}
|
text={item.text}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.type === 'gemini_content' && (
|
{item.type === 'gemini_content' && (
|
||||||
|
@ -51,6 +54,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
text={item.text}
|
text={item.text}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.type === 'info' && <InfoMessage text={item.text} />}
|
{item.type === 'info' && <InfoMessage text={item.text} />}
|
||||||
|
@ -78,6 +82,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
toolCalls={item.tools}
|
toolCalls={item.tools}
|
||||||
groupId={item.id}
|
groupId={item.id}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
config={config}
|
config={config}
|
||||||
isFocused={isFocused}
|
isFocused={isFocused}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -9,7 +9,8 @@ import { Text, Box, useInput } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
import { cpSlice, cpLen, TextBuffer } from './shared/text-buffer.js';
|
import { TextBuffer } from './shared/text-buffer.js';
|
||||||
|
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
|
@ -21,12 +21,16 @@ interface ThemeDialogProps {
|
||||||
onHighlight: (themeName: string | undefined) => void;
|
onHighlight: (themeName: string | undefined) => void;
|
||||||
/** The settings object */
|
/** The settings object */
|
||||||
settings: LoadedSettings;
|
settings: LoadedSettings;
|
||||||
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeDialog({
|
export function ThemeDialog({
|
||||||
onSelect,
|
onSelect,
|
||||||
onHighlight,
|
onHighlight,
|
||||||
settings,
|
settings,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
}: ThemeDialogProps): React.JSX.Element {
|
}: ThemeDialogProps): React.JSX.Element {
|
||||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||||
SettingScope.User,
|
SettingScope.User,
|
||||||
|
@ -94,6 +98,34 @@ export function ThemeDialog({
|
||||||
: `(Modified in ${otherScope})`;
|
: `(Modified in ${otherScope})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constants for calculating preview pane layout.
|
||||||
|
// These values are based on the JSX structure below.
|
||||||
|
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||||
|
// A safety margin to prevent text from touching the border.
|
||||||
|
// This is a complete hack unrelated to the 0.9 used in App.tsx
|
||||||
|
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
|
||||||
|
// Combined horizontal padding from the dialog and preview pane.
|
||||||
|
const TOTAL_HORIZONTAL_PADDING = 4;
|
||||||
|
const colorizeCodeWidth = Math.max(
|
||||||
|
Math.floor(
|
||||||
|
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
|
||||||
|
PREVIEW_PANE_WIDTH_PERCENTAGE *
|
||||||
|
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vertical space taken by elements other than the two code blocks in the preview pane.
|
||||||
|
// Includes "Preview" title, borders, padding, and margin between blocks.
|
||||||
|
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 7;
|
||||||
|
const availableTerminalHeightCodeBlock = availableTerminalHeight
|
||||||
|
? Math.max(
|
||||||
|
Math.floor(
|
||||||
|
(availableTerminalHeight - PREVIEW_PANE_FIXED_VERTICAL_SPACE) / 2,
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
|
@ -155,6 +187,8 @@ def fibonacci(n):
|
||||||
a, b = b, a + b
|
a, b = b, a + b
|
||||||
return a`,
|
return a`,
|
||||||
'python',
|
'python',
|
||||||
|
availableTerminalHeightCodeBlock,
|
||||||
|
colorizeCodeWidth,
|
||||||
)}
|
)}
|
||||||
<Box marginTop={1} />
|
<Box marginTop={1} />
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
|
@ -165,6 +199,8 @@ def fibonacci(n):
|
||||||
-This line was deleted.
|
-This line was deleted.
|
||||||
+This line was added.
|
+This line was added.
|
||||||
`}
|
`}
|
||||||
|
availableTerminalHeight={availableTerminalHeightCodeBlock}
|
||||||
|
terminalWidth={colorizeCodeWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -16,6 +16,9 @@ describe('<DiffRenderer />', () => {
|
||||||
mockColorizeCode.mockClear();
|
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', () => {
|
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||||
const newFileDiffContent = `
|
const newFileDiffContent = `
|
||||||
diff --git a/test.py b/test.py
|
diff --git a/test.py b/test.py
|
||||||
|
@ -27,11 +30,17 @@ index 0000000..e69de29
|
||||||
+print("hello world")
|
+print("hello world")
|
||||||
`;
|
`;
|
||||||
render(
|
render(
|
||||||
<DiffRenderer diffContent={newFileDiffContent} filename="test.py" />,
|
<DiffRenderer
|
||||||
|
diffContent={newFileDiffContent}
|
||||||
|
filename="test.py"
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||||
'print("hello world")',
|
'print("hello world")',
|
||||||
'python',
|
'python',
|
||||||
|
undefined,
|
||||||
|
80,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -46,9 +55,18 @@ index 0000000..e69de29
|
||||||
+some content
|
+some content
|
||||||
`;
|
`;
|
||||||
render(
|
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', () => {
|
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 @@
|
@@ -0,0 +1 @@
|
||||||
+some text content
|
+some text content
|
||||||
`;
|
`;
|
||||||
render(<DiffRenderer diffContent={newFileDiffContent} />);
|
render(
|
||||||
expect(mockColorizeCode).toHaveBeenCalledWith('some text content', null);
|
<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)', () => {
|
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
|
<DiffRenderer
|
||||||
diffContent={existingFileDiffContent}
|
diffContent={existingFileDiffContent}
|
||||||
filename="test.txt"
|
filename="test.txt"
|
||||||
|
terminalWidth={80}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
|
// 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
|
+++ b/file.txt
|
||||||
`;
|
`;
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<DiffRenderer diffContent={noChangeDiff} filename="file.txt" />,
|
<DiffRenderer
|
||||||
|
diffContent={noChangeDiff}
|
||||||
|
filename="file.txt"
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toContain('No changes detected');
|
expect(lastFrame()).toContain('No changes detected');
|
||||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty diff content', () => {
|
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(lastFrame()).toContain('No diff content');
|
||||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@ -130,7 +162,11 @@ index 123..456 100644
|
||||||
context line 11
|
context line 11
|
||||||
`;
|
`;
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<DiffRenderer diffContent={diffWithGap} filename="file.txt" />,
|
<DiffRenderer
|
||||||
|
diffContent={diffWithGap}
|
||||||
|
filename="file.txt"
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('═'); // Check for the border character used in the gap
|
expect(output).toContain('═'); // Check for the border character used in the gap
|
||||||
|
@ -161,7 +197,11 @@ index abc..def 100644
|
||||||
context line 15
|
context line 15
|
||||||
`;
|
`;
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<DiffRenderer diffContent={diffWithSmallGap} filename="file.txt" />,
|
<DiffRenderer
|
||||||
|
diffContent={diffWithSmallGap}
|
||||||
|
filename="file.txt"
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('═'); // Ensure no separator is rendered
|
expect(output).not.toContain('═'); // Ensure no separator is rendered
|
||||||
|
@ -171,7 +211,7 @@ index abc..def 100644
|
||||||
expect(output).toContain('context line 11');
|
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 = `
|
const diffWithMultipleHunks = `
|
||||||
diff --git a/multi.js b/multi.js
|
diff --git a/multi.js b/multi.js
|
||||||
index 123..789 100644
|
index 123..789 100644
|
||||||
|
@ -188,25 +228,56 @@ index 123..789 100644
|
||||||
+const anotherNew = 'test';
|
+const anotherNew = 'test';
|
||||||
console.log('end of second hunk');
|
console.log('end of second hunk');
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
terminalWidth: 80,
|
||||||
|
height: undefined,
|
||||||
|
expected: `1 console.log('first hunk');
|
||||||
|
2 - const oldVar = 1;
|
||||||
|
2 + const newVar = 1;
|
||||||
|
3 console.log('end of first hunk');
|
||||||
|
════════════════════════════════════════════════════════════════════════════════
|
||||||
|
20 console.log('second hunk');
|
||||||
|
21 - const anotherOld = 'test';
|
||||||
|
21 + const anotherNew = 'test';
|
||||||
|
22 console.log('end of second hunk');`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
terminalWidth: 80,
|
||||||
|
height: 6,
|
||||||
|
expected: `... first 4 lines hidden ...
|
||||||
|
════════════════════════════════════════════════════════════════════════════════
|
||||||
|
20 console.log('second hunk');
|
||||||
|
21 - const anotherOld = 'test';
|
||||||
|
21 + const anotherNew = 'test';
|
||||||
|
22 console.log('end of second hunk');`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
terminalWidth: 30,
|
||||||
|
height: 6,
|
||||||
|
expected: `... first 10 lines hidden ...
|
||||||
|
'test';
|
||||||
|
21 + const anotherNew =
|
||||||
|
'test';
|
||||||
|
22 console.log('end of
|
||||||
|
second hunk');`,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'with terminalWidth $terminalWidth and height $height',
|
||||||
|
({ terminalWidth, height, expected }) => {
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<DiffRenderer diffContent={diffWithMultipleHunks} filename="multi.js" />,
|
<DiffRenderer
|
||||||
|
diffContent={diffWithMultipleHunks}
|
||||||
|
filename="multi.js"
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
availableTerminalHeight={height}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
|
||||||
// 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', () => {
|
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
|
\\ No newline at end of file
|
||||||
`;
|
`;
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<DiffRenderer diffContent={newFileDiff} filename="TEST" />,
|
<DiffRenderer
|
||||||
|
diffContent={newFileDiff}
|
||||||
|
filename="TEST"
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('1 - const oldVar = 1;');
|
expect(output).toEqual(`1 - const oldVar = 1;
|
||||||
expect(output).toContain('1 + const newVar = 1;');
|
1 + const newVar = 1;
|
||||||
expect(output).toContain('═');
|
════════════════════════════════════════════════════════════════════════════════
|
||||||
expect(output).toContain("20 - const anotherOld = 'test';");
|
20 - const anotherOld = 'test';
|
||||||
expect(output).toContain("20 + const anotherNew = 'test';");
|
20 + const anotherNew = 'test';`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly render a new file with no file extension correctly', () => {
|
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
|
\\ No newline at end of file
|
||||||
`;
|
`;
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<DiffRenderer diffContent={newFileDiff} filename="Dockerfile" />,
|
<DiffRenderer
|
||||||
|
diffContent={newFileDiff}
|
||||||
|
filename="Dockerfile"
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
expect(output).toEqual(`1 FROM node:14
|
||||||
expect(output).toContain('1 FROM node:14');
|
2 RUN npm install
|
||||||
expect(output).toContain('2 RUN npm install');
|
3 RUN npm run build`);
|
||||||
expect(output).toContain('3 RUN npm run build');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||||
|
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||||
|
|
||||||
interface DiffLine {
|
interface DiffLine {
|
||||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||||
|
@ -90,6 +91,8 @@ interface DiffRendererProps {
|
||||||
diffContent: string;
|
diffContent: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
tabWidth?: number;
|
tabWidth?: number;
|
||||||
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||||
|
@ -98,6 +101,8 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
diffContent,
|
diffContent,
|
||||||
filename,
|
filename,
|
||||||
tabWidth = DEFAULT_TAB_WIDTH,
|
tabWidth = DEFAULT_TAB_WIDTH,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
}) => {
|
}) => {
|
||||||
if (!diffContent || typeof diffContent !== 'string') {
|
if (!diffContent || typeof diffContent !== 'string') {
|
||||||
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
||||||
|
@ -136,9 +141,20 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
const language = fileExtension
|
const language = fileExtension
|
||||||
? getLanguageFromExtension(fileExtension)
|
? getLanguageFromExtension(fileExtension)
|
||||||
: null;
|
: null;
|
||||||
renderedOutput = colorizeCode(addedContent, language);
|
renderedOutput = colorizeCode(
|
||||||
|
addedContent,
|
||||||
|
language,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
renderedOutput = renderDiffContent(parsedLines, filename, tabWidth);
|
renderedOutput = renderDiffContent(
|
||||||
|
parsedLines,
|
||||||
|
filename,
|
||||||
|
tabWidth,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderedOutput;
|
return renderedOutput;
|
||||||
|
@ -146,8 +162,10 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
|
|
||||||
const renderDiffContent = (
|
const renderDiffContent = (
|
||||||
parsedLines: DiffLine[],
|
parsedLines: DiffLine[],
|
||||||
filename?: string,
|
filename: string | undefined,
|
||||||
tabWidth = DEFAULT_TAB_WIDTH,
|
tabWidth = DEFAULT_TAB_WIDTH,
|
||||||
|
availableTerminalHeight: number | undefined,
|
||||||
|
terminalWidth: number,
|
||||||
) => {
|
) => {
|
||||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||||
const normalizedLines = parsedLines.map((line) => ({
|
const normalizedLines = parsedLines.map((line) => ({
|
||||||
|
@ -191,7 +209,11 @@ const renderDiffContent = (
|
||||||
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" key={key}>
|
<MaxSizedBox
|
||||||
|
maxHeight={availableTerminalHeight}
|
||||||
|
maxWidth={terminalWidth}
|
||||||
|
key={key}
|
||||||
|
>
|
||||||
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
||||||
// Determine the relevant line number for gap calculation based on type
|
// Determine the relevant line number for gap calculation based on type
|
||||||
let relevantLineNumberForGapCalc: number | null = null;
|
let relevantLineNumberForGapCalc: number | null = null;
|
||||||
|
@ -209,16 +231,9 @@ const renderDiffContent = (
|
||||||
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
||||||
) {
|
) {
|
||||||
acc.push(
|
acc.push(
|
||||||
<Box
|
<Box key={`gap-${index}`}>
|
||||||
key={`gap-${index}`}
|
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
|
||||||
width="100%"
|
</Box>,
|
||||||
borderTop={true}
|
|
||||||
borderBottom={false}
|
|
||||||
borderRight={false}
|
|
||||||
borderLeft={false}
|
|
||||||
borderStyle="double"
|
|
||||||
borderColor={Colors.Gray}
|
|
||||||
></Box>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -271,7 +286,7 @@ const renderDiffContent = (
|
||||||
);
|
);
|
||||||
return acc;
|
return acc;
|
||||||
}, [])}
|
}, [])}
|
||||||
</Box>
|
</MaxSizedBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,13 +12,15 @@ import { Colors } from '../../colors.js';
|
||||||
interface GeminiMessageProps {
|
interface GeminiMessageProps {
|
||||||
text: string;
|
text: string;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||||
text,
|
text,
|
||||||
isPending,
|
isPending,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
}) => {
|
}) => {
|
||||||
const prefix = '✦ ';
|
const prefix = '✦ ';
|
||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
|
@ -33,6 +35,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||||
text={text}
|
text={text}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -11,7 +11,8 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
interface GeminiMessageContentProps {
|
interface GeminiMessageContentProps {
|
||||||
text: string;
|
text: string;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -24,6 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||||
text,
|
text,
|
||||||
isPending,
|
isPending,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
}) => {
|
}) => {
|
||||||
const originalPrefix = '✦ ';
|
const originalPrefix = '✦ ';
|
||||||
const prefixWidth = originalPrefix.length;
|
const prefixWidth = originalPrefix.length;
|
||||||
|
@ -34,6 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||||
text={text}
|
text={text}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,7 +20,11 @@ describe('ToolConfirmationMessage', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<ToolConfirmationMessage confirmationDetails={confirmationDetails} />,
|
<ToolConfirmationMessage
|
||||||
|
confirmationDetails={confirmationDetails}
|
||||||
|
availableTerminalHeight={30}
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('URLs to fetch:');
|
expect(lastFrame()).not.toContain('URLs to fetch:');
|
||||||
|
@ -39,7 +43,11 @@ describe('ToolConfirmationMessage', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<ToolConfirmationMessage confirmationDetails={confirmationDetails} />,
|
<ToolConfirmationMessage
|
||||||
|
confirmationDetails={confirmationDetails}
|
||||||
|
availableTerminalHeight={30}
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(lastFrame()).toContain('URLs to fetch:');
|
expect(lastFrame()).toContain('URLs to fetch:');
|
||||||
|
|
|
@ -19,17 +19,26 @@ import {
|
||||||
RadioButtonSelect,
|
RadioButtonSelect,
|
||||||
RadioSelectItem,
|
RadioSelectItem,
|
||||||
} from '../shared/RadioButtonSelect.js';
|
} from '../shared/RadioButtonSelect.js';
|
||||||
|
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||||
|
|
||||||
export interface ToolConfirmationMessageProps {
|
export interface ToolConfirmationMessageProps {
|
||||||
confirmationDetails: ToolCallConfirmationDetails;
|
confirmationDetails: ToolCallConfirmationDetails;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolConfirmationMessage: React.FC<
|
export const ToolConfirmationMessage: React.FC<
|
||||||
ToolConfirmationMessageProps
|
ToolConfirmationMessageProps
|
||||||
> = ({ confirmationDetails, isFocused = true }) => {
|
> = ({
|
||||||
|
confirmationDetails,
|
||||||
|
isFocused = true,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
|
}) => {
|
||||||
const { onConfirm } = confirmationDetails;
|
const { onConfirm } = confirmationDetails;
|
||||||
|
const childWidth = terminalWidth - 2; // 2 for padding
|
||||||
|
|
||||||
useInput((_, key) => {
|
useInput((_, key) => {
|
||||||
if (!isFocused) return;
|
if (!isFocused) return;
|
||||||
|
@ -47,6 +56,35 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
RadioSelectItem<ToolConfirmationOutcome>
|
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.type === 'edit') {
|
||||||
if (confirmationDetails.isModifying) {
|
if (confirmationDetails.isModifying) {
|
||||||
return (
|
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?`;
|
question = `Apply this change?`;
|
||||||
options.push(
|
options.push(
|
||||||
{
|
{
|
||||||
|
@ -91,18 +120,18 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
},
|
},
|
||||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||||
);
|
);
|
||||||
|
bodyContent = (
|
||||||
|
<DiffRenderer
|
||||||
|
diffContent={confirmationDetails.fileDiff}
|
||||||
|
filename={confirmationDetails.fileName}
|
||||||
|
availableTerminalHeight={availableBodyContentHeight()}
|
||||||
|
terminalWidth={childWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (confirmationDetails.type === 'exec') {
|
} else if (confirmationDetails.type === 'exec') {
|
||||||
const executionProps =
|
const executionProps =
|
||||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||||
|
|
||||||
bodyContent = (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Box paddingX={1} marginLeft={1}>
|
|
||||||
<Text color={Colors.AccentCyan}>{executionProps.command}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
question = `Allow execution?`;
|
question = `Allow execution?`;
|
||||||
options.push(
|
options.push(
|
||||||
{
|
{
|
||||||
|
@ -115,12 +144,44 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
},
|
},
|
||||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
{ 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') {
|
} else if (confirmationDetails.type === 'info') {
|
||||||
const infoProps = confirmationDetails;
|
const infoProps = confirmationDetails;
|
||||||
const displayUrls =
|
const displayUrls =
|
||||||
infoProps.urls &&
|
infoProps.urls &&
|
||||||
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
|
!(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 = (
|
bodyContent = (
|
||||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||||
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
|
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
|
||||||
|
@ -134,19 +195,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 {
|
} else {
|
||||||
// mcp tool confirmation
|
// mcp tool confirmation
|
||||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||||
|
@ -177,7 +225,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1} minWidth="90%">
|
<Box flexDirection="column" padding={1} width={childWidth}>
|
||||||
{/* Body Content (Diff Renderer or Command Info) */}
|
{/* Body Content (Diff Renderer or Command Info) */}
|
||||||
{/* No separate context display here anymore for edits */}
|
{/* No separate context display here anymore for edits */}
|
||||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||||
|
@ -186,7 +234,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
|
|
||||||
{/* Confirmation Question */}
|
{/* Confirmation Question */}
|
||||||
<Box marginBottom={1} flexShrink={0}>
|
<Box marginBottom={1} flexShrink={0}>
|
||||||
<Text>{question}</Text>
|
<Text wrap="truncate">{question}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Select Input for Options */}
|
{/* Select Input for Options */}
|
||||||
|
|
|
@ -15,7 +15,8 @@ import { Config } from '@gemini-cli/core';
|
||||||
interface ToolGroupMessageProps {
|
interface ToolGroupMessageProps {
|
||||||
groupId: number;
|
groupId: number;
|
||||||
toolCalls: IndividualToolCallDisplay[];
|
toolCalls: IndividualToolCallDisplay[];
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -24,6 +25,7 @@ interface ToolGroupMessageProps {
|
||||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
toolCalls,
|
toolCalls,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
config,
|
config,
|
||||||
isFocused = true,
|
isFocused = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -33,6 +35,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
|
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
|
||||||
|
|
||||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
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
|
// 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
|
// note, after the CTA, this automatically moves over to the next 'confirming' tool
|
||||||
|
@ -41,6 +46,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
[toolCalls],
|
[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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
|
@ -69,7 +91,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
resultDisplay={tool.resultDisplay}
|
resultDisplay={tool.resultDisplay}
|
||||||
status={tool.status}
|
status={tool.status}
|
||||||
confirmationDetails={tool.confirmationDetails}
|
confirmationDetails={tool.confirmationDetails}
|
||||||
availableTerminalHeight={availableTerminalHeight - staticHeight}
|
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||||
|
terminalWidth={innerWidth}
|
||||||
emphasis={
|
emphasis={
|
||||||
isConfirming
|
isConfirming
|
||||||
? 'high'
|
? 'high'
|
||||||
|
@ -87,6 +110,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
confirmationDetails={tool.confirmationDetails}
|
confirmationDetails={tool.confirmationDetails}
|
||||||
config={config}
|
config={config}
|
||||||
isFocused={isFocused}
|
isFocused={isFocused}
|
||||||
|
availableTerminalHeight={
|
||||||
|
availableTerminalHeightPerToolMessage
|
||||||
|
}
|
||||||
|
terminalWidth={innerWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -60,7 +60,7 @@ describe('<ToolMessage />', () => {
|
||||||
description: 'A tool for testing',
|
description: 'A tool for testing',
|
||||||
resultDisplay: 'Test result',
|
resultDisplay: 'Test result',
|
||||||
status: ToolCallStatus.Success,
|
status: ToolCallStatus.Success,
|
||||||
availableTerminalHeight: 20,
|
terminalWidth: 80,
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
emphasis: 'medium',
|
emphasis: 'medium',
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,17 +11,18 @@ import { DiffRenderer } from './DiffRenderer.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||||
|
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||||
|
|
||||||
const STATIC_HEIGHT = 1;
|
const STATIC_HEIGHT = 1;
|
||||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||||
const STATUS_INDICATOR_WIDTH = 3;
|
const STATUS_INDICATOR_WIDTH = 3;
|
||||||
const MIN_LINES_SHOWN = 2; // show at least this many lines
|
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 type TextEmphasis = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
emphasis?: TextEmphasis;
|
emphasis?: TextEmphasis;
|
||||||
renderOutputAsMarkdown?: boolean;
|
renderOutputAsMarkdown?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -32,36 +33,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||||
resultDisplay,
|
resultDisplay,
|
||||||
status,
|
status,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
emphasis = 'medium',
|
emphasis = 'medium',
|
||||||
renderOutputAsMarkdown = true,
|
renderOutputAsMarkdown = true,
|
||||||
}) => {
|
}) => {
|
||||||
const resultIsString =
|
const availableHeight = availableTerminalHeight
|
||||||
typeof resultDisplay === 'string' && resultDisplay.trim().length > 0;
|
? Math.max(
|
||||||
const lines = React.useMemo(
|
|
||||||
() => (resultIsString ? resultDisplay.split('\n') : []),
|
|
||||||
[resultIsString, resultDisplay],
|
|
||||||
);
|
|
||||||
let contentHeightEstimate = Math.max(
|
|
||||||
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
|
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
|
||||||
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
||||||
);
|
)
|
||||||
// enforce minimum lines hidden (don't hide any otherwise)
|
: undefined;
|
||||||
if (lines.length - contentHeightEstimate < MIN_LINES_HIDDEN) {
|
|
||||||
contentHeightEstimate = lines.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate the overall string content if it's too long.
|
const childWidth = terminalWidth - 3; // account for padding.
|
||||||
// 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||||
|
@ -75,37 +58,32 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||||
/>
|
/>
|
||||||
{emphasis === 'high' && <TrailingIndicator />}
|
{emphasis === 'high' && <TrailingIndicator />}
|
||||||
</Box>
|
</Box>
|
||||||
{displayableResult && (
|
{resultDisplay && (
|
||||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
|
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{hiddenLines > 0 && (
|
{typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
|
||||||
<Box>
|
|
||||||
<Text color={Colors.Gray}>
|
|
||||||
... first {hiddenLines} line{hiddenLines === 1 ? '' : 's'}{' '}
|
|
||||||
hidden ...
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{typeof displayableResult === 'string' &&
|
|
||||||
renderOutputAsMarkdown && (
|
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<MarkdownDisplay
|
<MarkdownDisplay
|
||||||
text={displayableResult}
|
text={resultDisplay}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableHeight}
|
||||||
|
terminalWidth={childWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{typeof displayableResult === 'string' &&
|
{typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
|
||||||
!renderOutputAsMarkdown && (
|
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||||
<Box flexDirection="column">
|
<Box>
|
||||||
<Text>{displayableResult}</Text>
|
<Text wrap="wrap">{resultDisplay}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
</MaxSizedBox>
|
||||||
)}
|
)}
|
||||||
{typeof displayableResult !== 'string' && (
|
{typeof resultDisplay !== 'string' && (
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={displayableResult.fileDiff}
|
diffContent={resultDisplay.fileDiff}
|
||||||
filename={displayableResult.fileName}
|
filename={resultDisplay.fileName}
|
||||||
|
availableTerminalHeight={availableHeight}
|
||||||
|
terminalWidth={childWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -193,5 +171,8 @@ const ToolInfo: React.FC<ToolInfo> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const TrailingIndicator: React.FC = () => (
|
const TrailingIndicator: React.FC = () => (
|
||||||
<Text color={Colors.Foreground}> ←</Text>
|
<Text color={Colors.Foreground} wrap="truncate">
|
||||||
|
{' '}
|
||||||
|
←
|
||||||
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,303 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from 'ink-testing-library';
|
||||||
|
import { MaxSizedBox } from './MaxSizedBox.js';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('<MaxSizedBox />', () => {
|
||||||
|
it('renders children without truncation when they fit', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
|
<Box>
|
||||||
|
<Text>Hello, World!</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals('Hello, World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides lines when content exceeds maxHeight', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 3</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||||
|
Line 3`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 3</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals(`Line 1
|
||||||
|
... last 2 lines hidden ...`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps text that exceeds maxWidth', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={10} maxHeight={5}>
|
||||||
|
<Box>
|
||||||
|
<Text wrap="wrap">This is a long line of text</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).equals(`This is a
|
||||||
|
long line
|
||||||
|
of text`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed wrapping and non-wrapping segments', () => {
|
||||||
|
const multilineText = `This part will wrap around.
|
||||||
|
And has a line break.
|
||||||
|
Leading spaces preserved.`;
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={20} maxHeight={20}>
|
||||||
|
<Box>
|
||||||
|
<Text>Example</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>No Wrap: </Text>
|
||||||
|
<Text wrap="wrap">{multilineText}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Longer No Wrap: </Text>
|
||||||
|
<Text wrap="wrap">This part will wrap around.</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).equals(
|
||||||
|
`Example
|
||||||
|
No Wrap: This part
|
||||||
|
will wrap
|
||||||
|
around.
|
||||||
|
And has a
|
||||||
|
line break.
|
||||||
|
Leading
|
||||||
|
spaces
|
||||||
|
preserved.
|
||||||
|
Longer No Wrap: This
|
||||||
|
part
|
||||||
|
will
|
||||||
|
wrap
|
||||||
|
arou
|
||||||
|
nd.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles words longer than maxWidth by splitting them', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||||
|
<Box>
|
||||||
|
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).equals(`... …
|
||||||
|
istic
|
||||||
|
expia
|
||||||
|
lidoc
|
||||||
|
ious`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not truncate when maxHeight is undefined', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals(`Line 1
|
||||||
|
Line 2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows plural "lines" when more than one line is hidden', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 3</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||||
|
Line 3`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 3</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals(`Line 1
|
||||||
|
... last 2 lines hidden ...`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty box for empty children', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>,
|
||||||
|
);
|
||||||
|
// Expect an empty string or a box with nothing in it.
|
||||||
|
// Ink renders an empty box as an empty string.
|
||||||
|
expect(lastFrame()).equals('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps text with multi-byte unicode characters correctly', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||||
|
<Box>
|
||||||
|
<Text wrap="wrap">你好世界</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// "你好" has a visual width of 4. "世界" has a visual width of 4.
|
||||||
|
// With maxWidth=5, it should wrap after the second character.
|
||||||
|
expect(lastFrame()).equals(`你好
|
||||||
|
世界`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps text with multi-byte emoji characters correctly', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||||
|
<Box>
|
||||||
|
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each "🐶" has a visual width of 2.
|
||||||
|
// With maxWidth=5, it should wrap every 2 emojis.
|
||||||
|
expect(lastFrame()).equals(`🐶🐶
|
||||||
|
🐶🐶
|
||||||
|
🐶`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for additionalHiddenLinesCount', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 3</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
// 1 line is hidden by overflow, 5 are additionally hidden.
|
||||||
|
expect(lastFrame()).equals(`... first 7 lines hidden ...
|
||||||
|
Line 3`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles React.Fragment as a child', () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 1 from Fragment</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 2 from Fragment</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
<Box>
|
||||||
|
<Text>Line 3 direct child</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).equals(`Line 1 from Fragment
|
||||||
|
Line 2 from Fragment
|
||||||
|
Line 3 direct child`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips a long single text child from the top', () => {
|
||||||
|
const THIRTY_LINES = Array.from(
|
||||||
|
{ length: 30 },
|
||||||
|
(_, i) => `Line ${i + 1}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
|
<Box>
|
||||||
|
<Text>{THIRTY_LINES}</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
'... first 21 lines hidden ...',
|
||||||
|
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(lastFrame()).equals(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips a long single text child from the bottom', () => {
|
||||||
|
const THIRTY_LINES = Array.from(
|
||||||
|
{ length: 30 },
|
||||||
|
(_, i) => `Line ${i + 1}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
|
||||||
|
<Box>
|
||||||
|
<Text>{THIRTY_LINES}</Text>
|
||||||
|
</Box>
|
||||||
|
</MaxSizedBox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
|
||||||
|
'... last 21 lines hidden ...',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(lastFrame()).equals(expected);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,511 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import stringWidth from 'string-width';
|
||||||
|
import { Colors } from '../../colors.js';
|
||||||
|
import { toCodePoints } from '../../utils/textUtils.js';
|
||||||
|
|
||||||
|
const enableDebugLog = true;
|
||||||
|
|
||||||
|
function debugReportError(message: string, element: React.ReactNode) {
|
||||||
|
if (!enableDebugLog) return;
|
||||||
|
|
||||||
|
if (!React.isValidElement(element)) {
|
||||||
|
console.error(
|
||||||
|
message,
|
||||||
|
`Invalid element: '${String(element)}' typeof=${typeof element}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceMessage = '<Unknown file>';
|
||||||
|
try {
|
||||||
|
const elementWithSource = element as {
|
||||||
|
_source?: { fileName?: string; lineNumber?: number };
|
||||||
|
};
|
||||||
|
const fileName = elementWithSource._source?.fileName;
|
||||||
|
const lineNumber = elementWithSource._source?.lineNumber;
|
||||||
|
sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error while trying to get file name:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
|
||||||
|
}
|
||||||
|
interface MaxSizedBoxProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight: number | undefined;
|
||||||
|
overflowDirection?: 'top' | 'bottom';
|
||||||
|
additionalHiddenLinesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React component that constrains the size of its children and provides
|
||||||
|
* content-aware truncation when the content exceeds the specified `maxHeight`.
|
||||||
|
*
|
||||||
|
* `MaxSizedBox` requires a specific structure for its children to correctly
|
||||||
|
* measure and render the content:
|
||||||
|
*
|
||||||
|
* 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
|
||||||
|
* single row of content.
|
||||||
|
* 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
|
||||||
|
* `<Text>` elements can be nested and there are no restrictions to Text
|
||||||
|
* element styling other than that non-wrapping text elements must be
|
||||||
|
* before wrapping text elements.
|
||||||
|
*
|
||||||
|
* **Constraints:**
|
||||||
|
* - **Box Properties:** Custom properties on the child `<Box>` elements are
|
||||||
|
* ignored. In debug mode, runtime checks will report errors for any
|
||||||
|
* unsupported properties.
|
||||||
|
* - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
|
||||||
|
* (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
|
||||||
|
* - **Element Types:** Runtime checks will warn if unsupported element types
|
||||||
|
* are used as children.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
|
* <Box>
|
||||||
|
* <Text>This is the first line.</Text>
|
||||||
|
* </Box>
|
||||||
|
* <Box>
|
||||||
|
* <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
|
||||||
|
* <Text>This is the rest of the line which will wrap if it's too long.</Text>
|
||||||
|
* </Box>
|
||||||
|
* <Box>
|
||||||
|
* <Text>
|
||||||
|
* Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
|
||||||
|
* </Text>
|
||||||
|
* </Box>
|
||||||
|
* </MaxSizedBox>
|
||||||
|
*/
|
||||||
|
export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||||
|
children,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
overflowDirection = 'top',
|
||||||
|
additionalHiddenLinesCount = 0,
|
||||||
|
}) => {
|
||||||
|
// When maxHeight is not set, we render the content normally rather
|
||||||
|
// than using our custom layout logic. This should slightly improve
|
||||||
|
// performance for the case where there is no height limit and is
|
||||||
|
// a useful debugging tool to ensure that our layouts are consist
|
||||||
|
// with the expected layout when there is no height limit.
|
||||||
|
// In the future we might choose to still apply our layout logic
|
||||||
|
// even in this case particularlly if there are cases where we
|
||||||
|
// intentionally diverse how certain layouts are rendered.
|
||||||
|
if (maxHeight === undefined) {
|
||||||
|
return (
|
||||||
|
<Box width={maxWidth} height={maxHeight} flexDirection="column">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxWidth === undefined) {
|
||||||
|
throw new Error('maxWidth must be defined when maxHeight is set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const laidOutStyledText: StyledText[][] = [];
|
||||||
|
function visitRows(element: React.ReactNode) {
|
||||||
|
if (!React.isValidElement(element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.type === Fragment) {
|
||||||
|
React.Children.forEach(element.props.children, visitRows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.type === Box) {
|
||||||
|
layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugReportError('MaxSizedBox children must be <Box> elements', element);
|
||||||
|
}
|
||||||
|
|
||||||
|
React.Children.forEach(children, visitRows);
|
||||||
|
|
||||||
|
const contentWillOverflow =
|
||||||
|
(laidOutStyledText.length > maxHeight && maxHeight > 0) ||
|
||||||
|
additionalHiddenLinesCount > 0;
|
||||||
|
const visibleContentHeight = contentWillOverflow ? maxHeight - 1 : maxHeight;
|
||||||
|
|
||||||
|
const hiddenLinesCount = Math.max(
|
||||||
|
0,
|
||||||
|
laidOutStyledText.length - visibleContentHeight,
|
||||||
|
);
|
||||||
|
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
|
||||||
|
|
||||||
|
const visibleStyledText =
|
||||||
|
hiddenLinesCount > 0
|
||||||
|
? overflowDirection === 'top'
|
||||||
|
? laidOutStyledText.slice(
|
||||||
|
laidOutStyledText.length - visibleContentHeight,
|
||||||
|
)
|
||||||
|
: laidOutStyledText.slice(0, visibleContentHeight)
|
||||||
|
: laidOutStyledText;
|
||||||
|
|
||||||
|
const visibleLines = visibleStyledText.map((line, index) => (
|
||||||
|
<Box key={index}>
|
||||||
|
{line.length > 0 ? (
|
||||||
|
line.map((segment, segIndex) => (
|
||||||
|
<Text key={segIndex} {...segment.props}>
|
||||||
|
{segment.text}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text> </Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
|
||||||
|
{totalHiddenLines > 0 && overflowDirection === 'top' && (
|
||||||
|
<Text color={Colors.Gray} wrap="truncate">
|
||||||
|
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||||
|
hidden ...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{visibleLines}
|
||||||
|
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
|
||||||
|
<Text color={Colors.Gray} wrap="truncate">
|
||||||
|
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||||
|
hidden ...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define a type for styled text segments
|
||||||
|
interface StyledText {
|
||||||
|
text: string;
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single row of content within the MaxSizedBox.
|
||||||
|
*
|
||||||
|
* A row can contain segments that are not wrapped, followed by segments that
|
||||||
|
* are. This is a minimal implementation that only supports the functionality
|
||||||
|
* needed today.
|
||||||
|
*/
|
||||||
|
interface Row {
|
||||||
|
noWrapSegments: StyledText[];
|
||||||
|
segments: StyledText[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the child elements of MaxSizedBox into an array of `Row` objects.
|
||||||
|
*
|
||||||
|
* This function expects a specific child structure to function correctly:
|
||||||
|
* 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
|
||||||
|
* outer box is primarily for structure and is not directly rendered.
|
||||||
|
* 2. Inside the outer `<Box>`, there should be one or more children. Each of
|
||||||
|
* these children must be a `<Box>` that represents a row.
|
||||||
|
* 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
|
||||||
|
*
|
||||||
|
* The structure should look like this:
|
||||||
|
* <MaxSizedBox>
|
||||||
|
* <Box> // Row 1
|
||||||
|
* <Text>...</Text>
|
||||||
|
* <Text>...</Text>
|
||||||
|
* </Box>
|
||||||
|
* <Box> // Row 2
|
||||||
|
* <Text>...</Text>
|
||||||
|
* </Box>
|
||||||
|
* </MaxSizedBox>
|
||||||
|
*
|
||||||
|
* It is an error for a <Text> child without wrapping to appear after a
|
||||||
|
* <Text> child with wrapping within the same row Box.
|
||||||
|
*
|
||||||
|
* @param element The React node to flatten.
|
||||||
|
* @returns An array of `Row` objects.
|
||||||
|
*/
|
||||||
|
function visitBoxRow(element: React.ReactNode): Row {
|
||||||
|
if (!React.isValidElement(element) || element.type !== Box) {
|
||||||
|
debugReportError(
|
||||||
|
`All children of MaxSizedBox must be <Box> elements`,
|
||||||
|
element,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
noWrapSegments: [{ text: '<ERROR>', props: {} }],
|
||||||
|
segments: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableDebugLog) {
|
||||||
|
const boxProps = element.props;
|
||||||
|
// Ensure the Box has no props other than the default ones and key.
|
||||||
|
let maxExpectedProps = 4;
|
||||||
|
if (boxProps.children !== undefined) {
|
||||||
|
// Allow the key prop, which is automatically added by React.
|
||||||
|
maxExpectedProps += 1;
|
||||||
|
}
|
||||||
|
if (boxProps.flexDirection !== 'row') {
|
||||||
|
debugReportError(
|
||||||
|
'MaxSizedBox children must have flexDirection="row".',
|
||||||
|
element,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.keys(boxProps).length > maxExpectedProps) {
|
||||||
|
debugReportError(
|
||||||
|
`Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
|
||||||
|
boxProps,
|
||||||
|
).join(', ')}`,
|
||||||
|
element,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row: Row = {
|
||||||
|
noWrapSegments: [],
|
||||||
|
segments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasSeenWrapped = false;
|
||||||
|
|
||||||
|
function visitRowChild(
|
||||||
|
element: React.ReactNode,
|
||||||
|
parentProps: Record<string, unknown> | undefined,
|
||||||
|
) {
|
||||||
|
if (element === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof element === 'string' || typeof element === 'number') {
|
||||||
|
const text = String(element);
|
||||||
|
// Ignore empty strings as they don't need to be rendered.
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment: StyledText = { text, props: parentProps ?? {} };
|
||||||
|
|
||||||
|
// Check the 'wrap' property from the merged props to decide the segment type.
|
||||||
|
if (parentProps === undefined || parentProps.wrap === 'wrap') {
|
||||||
|
hasSeenWrapped = true;
|
||||||
|
row.segments.push(segment);
|
||||||
|
} else {
|
||||||
|
if (!hasSeenWrapped) {
|
||||||
|
row.noWrapSegments.push(segment);
|
||||||
|
} else {
|
||||||
|
// put in in the wrapped segment as the row is already stuck in wrapped mode.
|
||||||
|
row.segments.push(segment);
|
||||||
|
debugReportError(
|
||||||
|
'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
|
||||||
|
element,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!React.isValidElement(element)) {
|
||||||
|
debugReportError('Invalid element.', element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === Fragment) {
|
||||||
|
const fragmentChildren = element.props.children;
|
||||||
|
React.Children.forEach(fragmentChildren, (child) =>
|
||||||
|
visitRowChild(child, parentProps),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type !== Text) {
|
||||||
|
debugReportError(
|
||||||
|
'Children of a row Box must be <Text> elements.',
|
||||||
|
element,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge props from parent <Text> elements. Child props take precedence.
|
||||||
|
const { children, ...currentProps } = element.props;
|
||||||
|
const mergedProps =
|
||||||
|
parentProps === undefined
|
||||||
|
? currentProps
|
||||||
|
: { ...parentProps, ...currentProps };
|
||||||
|
React.Children.forEach(children, (child) =>
|
||||||
|
visitRowChild(child, mergedProps),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
React.Children.forEach(element.props.children, (child) =>
|
||||||
|
visitRowChild(child, undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutInkElementAsStyledText(
|
||||||
|
element: React.ReactElement,
|
||||||
|
maxWidth: number,
|
||||||
|
output: StyledText[][],
|
||||||
|
) {
|
||||||
|
const row = visitBoxRow(element);
|
||||||
|
if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
|
||||||
|
// Return a single empty line if there are no segments to display
|
||||||
|
output.push([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: StyledText[][] = [];
|
||||||
|
const nonWrappingContent: StyledText[] = [];
|
||||||
|
let noWrappingWidth = 0;
|
||||||
|
|
||||||
|
// First, lay out the non-wrapping segments
|
||||||
|
row.noWrapSegments.forEach((segment) => {
|
||||||
|
nonWrappingContent.push(segment);
|
||||||
|
noWrappingWidth += stringWidth(segment.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.segments.length === 0) {
|
||||||
|
// This is a bit of a special case when there are no segments that allow
|
||||||
|
// wrapping. It would be ideal to unify.
|
||||||
|
const lines: StyledText[][] = [];
|
||||||
|
let currentLine: StyledText[] = [];
|
||||||
|
nonWrappingContent.forEach((segment) => {
|
||||||
|
const textLines = segment.text.split('\n');
|
||||||
|
textLines.forEach((text, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = [];
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
currentLine.push({ text, props: segment.props });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
currentLine.length > 0 ||
|
||||||
|
(nonWrappingContent.length > 0 &&
|
||||||
|
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
|
||||||
|
) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
output.push(...lines);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableWidth = maxWidth - noWrappingWidth;
|
||||||
|
|
||||||
|
if (availableWidth < 1) {
|
||||||
|
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
|
||||||
|
output.push(nonWrappingContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, lay out the wrapping segments
|
||||||
|
let wrappingPart: StyledText[] = [];
|
||||||
|
let wrappingPartWidth = 0;
|
||||||
|
|
||||||
|
function addWrappingPartToLines() {
|
||||||
|
if (lines.length === 0) {
|
||||||
|
lines.push([...nonWrappingContent, ...wrappingPart]);
|
||||||
|
} else {
|
||||||
|
if (noWrappingWidth > 0) {
|
||||||
|
lines.push([
|
||||||
|
...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
|
||||||
|
...wrappingPart,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
lines.push(wrappingPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrappingPart = [];
|
||||||
|
wrappingPartWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToWrappingPart(text: string, props: Record<string, unknown>) {
|
||||||
|
if (
|
||||||
|
wrappingPart.length > 0 &&
|
||||||
|
wrappingPart[wrappingPart.length - 1].props === props
|
||||||
|
) {
|
||||||
|
wrappingPart[wrappingPart.length - 1].text += text;
|
||||||
|
} else {
|
||||||
|
wrappingPart.push({ text, props });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.segments.forEach((segment) => {
|
||||||
|
const linesFromSegment = segment.text.split('\n');
|
||||||
|
|
||||||
|
linesFromSegment.forEach((lineText, lineIndex) => {
|
||||||
|
if (lineIndex > 0) {
|
||||||
|
addWrappingPartToLines();
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = lineText.split(/(\s+)/); // Split by whitespace
|
||||||
|
|
||||||
|
words.forEach((word) => {
|
||||||
|
if (!word) return;
|
||||||
|
const wordWidth = stringWidth(word);
|
||||||
|
|
||||||
|
if (
|
||||||
|
wrappingPartWidth + wordWidth > availableWidth &&
|
||||||
|
wrappingPartWidth > 0
|
||||||
|
) {
|
||||||
|
addWrappingPartToLines();
|
||||||
|
if (/^\s+$/.test(word)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordWidth > availableWidth) {
|
||||||
|
// Word is too long, needs to be split across lines
|
||||||
|
const wordAsCodePoints = toCodePoints(word);
|
||||||
|
let remainingWordAsCodePoints = wordAsCodePoints;
|
||||||
|
while (remainingWordAsCodePoints.length > 0) {
|
||||||
|
let splitIndex = 0;
|
||||||
|
let currentSplitWidth = 0;
|
||||||
|
for (const char of remainingWordAsCodePoints) {
|
||||||
|
const charWidth = stringWidth(char);
|
||||||
|
if (
|
||||||
|
wrappingPartWidth + currentSplitWidth + charWidth >
|
||||||
|
availableWidth
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentSplitWidth += charWidth;
|
||||||
|
splitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (splitIndex > 0) {
|
||||||
|
const part = remainingWordAsCodePoints
|
||||||
|
.slice(0, splitIndex)
|
||||||
|
.join('');
|
||||||
|
addToWrappingPart(part, segment.props);
|
||||||
|
wrappingPartWidth += stringWidth(part);
|
||||||
|
remainingWordAsCodePoints =
|
||||||
|
remainingWordAsCodePoints.slice(splitIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingWordAsCodePoints.length > 0) {
|
||||||
|
addWrappingPartToLines();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addToWrappingPart(word, segment.props);
|
||||||
|
wrappingPartWidth += wordWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Split omits a trailing newline, so we need to handle it here
|
||||||
|
if (segment.text.endsWith('\n')) {
|
||||||
|
addWrappingPartToLines();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wrappingPart.length > 0) {
|
||||||
|
addWrappingPartToLines();
|
||||||
|
}
|
||||||
|
output.push(...lines);
|
||||||
|
}
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import SelectInput, {
|
import SelectInput, {
|
||||||
type ItemProps as InkSelectItemProps,
|
type ItemProps as InkSelectItemProps,
|
||||||
type IndicatorProps as InkSelectIndicatorProps,
|
type IndicatorProps as InkSelectIndicatorProps,
|
||||||
|
@ -78,11 +78,12 @@ export function RadioButtonSelect<T>({
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
}: InkSelectIndicatorProps): React.JSX.Element {
|
}: InkSelectIndicatorProps): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Box marginRight={1}>
|
<Text
|
||||||
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
|
color={isSelected ? Colors.AccentGreen : Colors.Foreground}
|
||||||
{isSelected ? '●' : '○'}
|
wrap="truncate"
|
||||||
|
>
|
||||||
|
{isSelected ? '● ' : '○ '}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,14 +114,18 @@ export function RadioButtonSelect<T>({
|
||||||
itemWithThemeProps.themeTypeDisplay
|
itemWithThemeProps.themeTypeDisplay
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<Text color={textColor}>
|
<Text color={textColor} wrap="truncate">
|
||||||
{itemWithThemeProps.themeNameDisplay}{' '}
|
{itemWithThemeProps.themeNameDisplay}{' '}
|
||||||
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
|
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Text color={textColor}>{label}</Text>;
|
return (
|
||||||
|
<Text color={textColor} wrap="truncate">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
initialIndex = initialIndex ?? 0;
|
initialIndex = initialIndex ?? 0;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import pathMod from 'path';
|
||||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
import { unescapePath } from '@gemini-cli/core';
|
import { unescapePath } from '@gemini-cli/core';
|
||||||
|
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
|
||||||
|
|
||||||
export type Direction =
|
export type Direction =
|
||||||
| 'left'
|
| 'left'
|
||||||
|
@ -69,28 +70,6 @@ function clamp(v: number, min: number, max: number): number {
|
||||||
return v < min ? min : v > max ? max : v;
|
return v < min ? min : v > max ? max : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* -------------------------------------------------------------------------
|
|
||||||
* Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
|
|
||||||
* code units so that surrogate‑pair emoji count as one "column".)
|
|
||||||
* ---------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export function toCodePoints(str: string): string[] {
|
|
||||||
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
|
||||||
// surrogate pairs correctly.
|
|
||||||
return Array.from(str);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cpLen(str: string): number {
|
|
||||||
return toCodePoints(str).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cpSlice(str: string, start: number, end?: number): string {
|
|
||||||
// Slice by code‑point indices and re‑join.
|
|
||||||
const arr = toCodePoints(str).slice(start, end);
|
|
||||||
return arr.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
|
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
|
||||||
* ---------------------------------------------------------------------- */
|
* ---------------------------------------------------------------------- */
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text, Box } from 'ink';
|
||||||
import { common, createLowlight } from 'lowlight';
|
import { common, createLowlight } from 'lowlight';
|
||||||
import type {
|
import type {
|
||||||
Root,
|
Root,
|
||||||
|
@ -16,6 +16,7 @@ import type {
|
||||||
} from 'hast';
|
} from 'hast';
|
||||||
import { themeManager } from '../themes/theme-manager.js';
|
import { themeManager } from '../themes/theme-manager.js';
|
||||||
import { Theme } from '../themes/theme.js';
|
import { Theme } from '../themes/theme.js';
|
||||||
|
import { MaxSizedBox } from '../components/shared/MaxSizedBox.js';
|
||||||
|
|
||||||
// Configure themeing and parsing utilities.
|
// Configure themeing and parsing utilities.
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
|
@ -84,6 +85,8 @@ function renderHastNode(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESERVED_LINES_FOR_TRUNCATION_MESSAGE = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders syntax-highlighted code for Ink applications using a selected theme.
|
* Renders syntax-highlighted code for Ink applications using a selected theme.
|
||||||
*
|
*
|
||||||
|
@ -94,6 +97,8 @@ function renderHastNode(
|
||||||
export function colorizeCode(
|
export function colorizeCode(
|
||||||
code: string,
|
code: string,
|
||||||
language: string | null,
|
language: string | null,
|
||||||
|
availableHeight?: number,
|
||||||
|
maxWidth?: number,
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
const codeToHighlight = code.replace(/\n$/, '');
|
const codeToHighlight = code.replace(/\n$/, '');
|
||||||
const activeTheme = themeManager.getActiveTheme();
|
const activeTheme = themeManager.getActiveTheme();
|
||||||
|
@ -101,15 +106,33 @@ export function colorizeCode(
|
||||||
try {
|
try {
|
||||||
// Render the HAST tree using the adapted theme
|
// Render the HAST tree using the adapted theme
|
||||||
// Apply the theme's default foreground color to the top-level Text element
|
// Apply the theme's default foreground color to the top-level Text element
|
||||||
const lines = codeToHighlight.split('\n');
|
let lines = codeToHighlight.split('\n');
|
||||||
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
||||||
|
|
||||||
|
let hiddenLinesCount = 0;
|
||||||
|
|
||||||
|
// Optimizaiton to avoid highlighting lines that cannot possibly be displayed.
|
||||||
|
if (availableHeight && lines.length > availableHeight) {
|
||||||
|
const sliceIndex =
|
||||||
|
lines.length - availableHeight + RESERVED_LINES_FOR_TRUNCATION_MESSAGE;
|
||||||
|
if (sliceIndex > 0) {
|
||||||
|
hiddenLinesCount = sliceIndex;
|
||||||
|
lines = lines.slice(sliceIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getHighlightedLines = (line: string) =>
|
const getHighlightedLines = (line: string) =>
|
||||||
!language || !lowlight.registered(language)
|
!language || !lowlight.registered(language)
|
||||||
? lowlight.highlightAuto(line)
|
? lowlight.highlightAuto(line)
|
||||||
: lowlight.highlight(language, line);
|
: lowlight.highlight(language, line);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text>
|
<MaxSizedBox
|
||||||
|
maxHeight={availableHeight}
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
additionalHiddenLinesCount={hiddenLinesCount}
|
||||||
|
overflowDirection="top"
|
||||||
|
>
|
||||||
{lines.map((line, index) => {
|
{lines.map((line, index) => {
|
||||||
const renderedNode = renderHastNode(
|
const renderedNode = renderHastNode(
|
||||||
getHighlightedLines(line),
|
getHighlightedLines(line),
|
||||||
|
@ -119,16 +142,17 @@ export function colorizeCode(
|
||||||
|
|
||||||
const contentToRender = renderedNode !== null ? renderedNode : line;
|
const contentToRender = renderedNode !== null ? renderedNode : line;
|
||||||
return (
|
return (
|
||||||
<Text key={index}>
|
<Box key={index}>
|
||||||
<Text color={activeTheme.colors.Gray}>
|
<Text color={activeTheme.colors.Gray}>
|
||||||
{`${String(index + 1).padStart(padWidth, ' ')} `}
|
{`${String(index + 1 + hiddenLinesCount).padStart(padWidth, ' ')} `}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={activeTheme.defaultColor}>{contentToRender}</Text>
|
<Text color={activeTheme.defaultColor} wrap="wrap">
|
||||||
{index < lines.length - 1 && '\n'}
|
{contentToRender}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Text>
|
</MaxSizedBox>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
@ -140,17 +164,20 @@ export function colorizeCode(
|
||||||
const lines = codeToHighlight.split('\n');
|
const lines = codeToHighlight.split('\n');
|
||||||
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
||||||
return (
|
return (
|
||||||
<Text>
|
<MaxSizedBox
|
||||||
|
maxHeight={availableHeight}
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
overflowDirection="top"
|
||||||
|
>
|
||||||
{lines.map((line, index) => (
|
{lines.map((line, index) => (
|
||||||
<Text key={index}>
|
<Box key={index}>
|
||||||
<Text color={activeTheme.defaultColor}>
|
<Text color={activeTheme.defaultColor}>
|
||||||
{`${String(index + 1).padStart(padWidth, ' ')} `}
|
{`${String(index + 1).padStart(padWidth, ' ')} `}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={activeTheme.colors.Gray}>{line}</Text>
|
<Text color={activeTheme.colors.Gray}>{line}</Text>
|
||||||
{index < lines.length - 1 && '\n'}
|
</Box>
|
||||||
</Text>
|
|
||||||
))}
|
))}
|
||||||
</Text>
|
</MaxSizedBox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,8 @@ import { colorizeCode } from './CodeColorizer.js';
|
||||||
interface MarkdownDisplayProps {
|
interface MarkdownDisplayProps {
|
||||||
text: string;
|
text: string;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constants for Markdown parsing and rendering
|
// Constants for Markdown parsing and rendering
|
||||||
|
@ -32,6 +33,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||||
text,
|
text,
|
||||||
isPending,
|
isPending,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
}) => {
|
}) => {
|
||||||
if (!text) return <></>;
|
if (!text) return <></>;
|
||||||
|
|
||||||
|
@ -65,6 +67,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||||
lang={codeBlockLang}
|
lang={codeBlockLang}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
inCodeBlock = false;
|
inCodeBlock = false;
|
||||||
|
@ -186,6 +189,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||||
lang={codeBlockLang}
|
lang={codeBlockLang}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -336,7 +340,8 @@ interface RenderCodeBlockProps {
|
||||||
content: string[];
|
content: string[];
|
||||||
lang: string | null;
|
lang: string | null;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight?: number;
|
||||||
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
||||||
|
@ -344,15 +349,17 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
||||||
lang,
|
lang,
|
||||||
isPending,
|
isPending,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
terminalWidth,
|
||||||
}) => {
|
}) => {
|
||||||
const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message
|
const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message
|
||||||
const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding
|
const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding
|
||||||
|
|
||||||
|
if (isPending && availableTerminalHeight !== undefined) {
|
||||||
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
|
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
|
||||||
0,
|
0,
|
||||||
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES,
|
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
if (content.length > MAX_CODE_LINES_WHEN_PENDING) {
|
if (content.length > MAX_CODE_LINES_WHEN_PENDING) {
|
||||||
if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) {
|
if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) {
|
||||||
// Not enough space to even show the message meaningfully
|
// Not enough space to even show the message meaningfully
|
||||||
|
@ -366,6 +373,8 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
||||||
const colorizedTruncatedCode = colorizeCode(
|
const colorizedTruncatedCode = colorizeCode(
|
||||||
truncatedContent.join('\n'),
|
truncatedContent.join('\n'),
|
||||||
lang,
|
lang,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth - CODE_BLOCK_PADDING * 2,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}>
|
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}>
|
||||||
|
@ -377,10 +386,20 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullContent = content.join('\n');
|
const fullContent = content.join('\n');
|
||||||
const colorizedCode = colorizeCode(fullContent, lang);
|
const colorizedCode = colorizeCode(
|
||||||
|
fullContent,
|
||||||
|
lang,
|
||||||
|
availableTerminalHeight,
|
||||||
|
terminalWidth - CODE_BLOCK_PADDING * 2,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}>
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
padding={CODE_BLOCK_PADDING}
|
||||||
|
width={terminalWidth}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
{colorizedCode}
|
{colorizedCode}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -45,3 +45,25 @@ export function isBinary(
|
||||||
// If no NULL bytes were found in the sample, we assume it's text.
|
// If no NULL bytes were found in the sample, we assume it's text.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* -------------------------------------------------------------------------
|
||||||
|
* Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
|
||||||
|
* code units so that surrogate‑pair emoji count as one "column".)
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function toCodePoints(str: string): string[] {
|
||||||
|
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
||||||
|
// surrogate pairs correctly.
|
||||||
|
return Array.from(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cpLen(str: string): number {
|
||||||
|
return toCodePoints(str).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cpSlice(str: string, start: number, end?: number): string {
|
||||||
|
// Slice by code‑point indices and re‑join.
|
||||||
|
const arr = toCodePoints(str).slice(start, end);
|
||||||
|
return arr.join('');
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue