feat(ui): add solid background color option for input prompt (#16563)

Co-authored-by: Alexander Farber <farber72@outlook.de>
This commit is contained in:
Jacob Richman
2026-01-26 15:23:54 -08:00
committed by GitHub
parent 7fbf470373
commit b5fe372b5b
40 changed files with 898 additions and 420 deletions

View File

@@ -21,7 +21,7 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
const { bannerText } = useBanner(bannerData, config);
const { showTips } = useTips();
@@ -33,7 +33,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
<Banner
width={mainAreaWidth}
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>

View File

@@ -50,7 +50,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
return (
<Box
flexDirection="column"
width={uiState.mainAreaWidth}
width={uiState.terminalWidth}
flexGrow={0}
flexShrink={0}
>
@@ -113,7 +113,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.mainAreaWidth}
width={uiState.terminalWidth}
hasFocus={uiState.showErrorDetails}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />

View File

@@ -72,7 +72,7 @@ describe('DialogManager', () => {
constrainHeight: false,
terminalHeight: 24,
staticExtraHeight: 0,
mainAreaWidth: 80,
terminalWidth: 80,
confirmUpdateExtensionRequests: [],
showIdeRestartPrompt: false,
proQuotaRequest: null,

View File

@@ -50,8 +50,12 @@ export const DialogManager = ({
const uiState = useUIState();
const uiActions = useUIActions();
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
const {
constrainHeight,
terminalHeight,
staticExtraHeight,
terminalWidth: uiTerminalWidth,
} = uiState;
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
@@ -147,7 +151,7 @@ export const DialogManager = ({
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
terminalWidth={mainAreaWidth}
terminalWidth={uiTerminalWidth}
/>
</Box>
);

View File

@@ -42,7 +42,7 @@ export const Footer: React.FC = () => {
promptTokenCount,
nightly,
isTrustedFolder,
mainAreaWidth,
terminalWidth,
} = {
model: uiState.currentModel,
targetDir: config.getTargetDir(),
@@ -55,7 +55,7 @@ export const Footer: React.FC = () => {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
mainAreaWidth: uiState.mainAreaWidth,
terminalWidth: uiState.terminalWidth,
};
const showMemoryUsage =
@@ -65,7 +65,7 @@ export const Footer: React.FC = () => {
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
@@ -76,7 +76,7 @@ export const Footer: React.FC = () => {
return (
<Box
justifyContent={justifyContent}
width={mainAreaWidth}
width={terminalWidth}
flexDirection="row"
alignItems="center"
paddingX={1}
@@ -134,7 +134,7 @@ export const Footer: React.FC = () => {
) : (
<Text color={theme.status.error}>
no sandbox
{mainAreaWidth >= 100 && (
{terminalWidth >= 100 && (
<Text color={theme.text.secondary}> (see /docs)</Text>
)}
</Text>
@@ -155,7 +155,7 @@ export const Footer: React.FC = () => {
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={mainAreaWidth}
terminalWidth={terminalWidth}
/>
</>
)}

View File

@@ -67,7 +67,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
)}
{itemForDisplay.type === 'user_shell' && (
<UserShellMessage text={itemForDisplay.text} />
<UserShellMessage text={itemForDisplay.text} width={terminalWidth} />
)}
{itemForDisplay.type === 'gemini' && (
<GeminiMessage

View File

@@ -42,6 +42,8 @@ import stripAnsi from 'strip-ansi';
import chalk from 'chalk';
import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
@@ -50,6 +52,9 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('clipboardy');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../hooks/useKittyKeyboardProtocol.js');
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
const mockSlashCommands: SlashCommand[] = [
{
@@ -260,6 +265,8 @@ describe('InputPrompt', () => {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
getUseBackgroundColor: () => true,
getTerminalBackground: () => undefined,
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
}),
@@ -1320,6 +1327,168 @@ describe('InputPrompt', () => {
unmount();
});
describe('Background Color Styles', () => {
beforeEach(() => {
vi.mocked(isLowColorDepth).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render with background color by default', async () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain('▀');
expect(frame).toContain('▄');
});
unmount();
});
it.each([
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
{ color: undefined, name: 'default (black)' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
])(
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
async ({ color }) => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
const isWhite =
color === 'white' || color === '#ffffff' || color === '#fff';
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
await waitFor(() => {
const frame = stdout.lastFrame();
// Use chalk to get the expected background color escape sequence
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
// Background color code should be present
expect(frame).toContain(bgCode);
// Background characters should be rendered
expect(frame).toContain('▀');
expect(frame).toContain('▄');
// Side borders should STILL be removed
expect(frame).not.toContain('│');
});
unmount();
},
);
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: '#333333',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
// It SHOULD NOT have vertical side borders (standard Box borders have │)
expect(frame).not.toContain('│');
});
unmount();
});
it('should handle 4-bit color mode (16 colors) as low color depth', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain('▀');
expect(frame).not.toContain('│');
});
unmount();
});
it('should render horizontal lines (but NO background) in 8-bit mode when background is blue', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: 'blue',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrame();
// Should NOT have background characters
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Should HAVE horizontal lines from the fallback Box borders
// Box style "round" uses these for top/bottom
expect(frame).toContain('─');
// Should NOT have vertical side borders
expect(frame).not.toContain('│');
});
unmount();
});
it('should render with plain borders when useBackgroundColor is false', async () => {
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Check for Box borders (round style uses unicode box chars)
expect(frame).toMatch(/[─│┐└┘┌]/);
});
unmount();
});
});
describe('cursor-based completion trigger', () => {
it.each([
{
@@ -1564,11 +1733,11 @@ describe('InputPrompt', () => {
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number];
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain(expected);
@@ -1621,11 +1790,11 @@ describe('InputPrompt', () => {
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
[number, number]
>;
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain(expected);
@@ -1645,11 +1814,11 @@ describe('InputPrompt', () => {
[1, 0],
[2, 0],
];
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
const lines = frame!.split('\n');
@@ -1673,15 +1842,15 @@ describe('InputPrompt', () => {
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
// Provide a visual-to-logical mapping for each visual line
mockBuffer.visualToLogicalMap = [
[0, 0], // 'hello' starts at col 0 of logical line 0
[1, 0], // '' (blank) is logical line 1, col 0
[2, 0], // 'world' is logical line 2, col 0
[0, 0],
[1, 0],
[2, 0],
];
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
// Check that all lines, including the empty one, are rendered.
@@ -2505,20 +2674,23 @@ describe('InputPrompt', () => {
stdin.write('\x12');
});
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-render-collapsed-match',
);
expect(stdout.lastFrame()).toContain('(r:)');
});
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-render-collapsed-match',
);
await act(async () => {
stdin.write('\u001B[C');
});
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-render-expanded-match',
);
// Just wait for any update to ensure it is stable.
// We could also wait for specific text if we knew it.
expect(stdout.lastFrame()).toContain('(r:)');
});
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-render-expanded-match',
);
unmount();
});
@@ -2637,28 +2809,28 @@ describe('InputPrompt', () => {
name: 'first line, first char',
relX: 0,
relY: 0,
mouseCol: 5,
mouseCol: 4,
mouseRow: 2,
},
{
name: 'first line, middle char',
relX: 6,
relY: 0,
mouseCol: 11,
mouseCol: 10,
mouseRow: 2,
},
{
name: 'second line, first char',
relX: 0,
relY: 1,
mouseCol: 5,
mouseCol: 4,
mouseRow: 3,
},
{
name: 'second line, end char',
relX: 5,
relY: 1,
mouseCol: 10,
mouseCol: 9,
mouseRow: 3,
},
])(
@@ -2685,7 +2857,7 @@ describe('InputPrompt', () => {
});
// Simulate left mouse press at calculated coordinates.
// Assumes inner box is at x=4, y=1 based on border(1)+padding(1)+prompt(2) and border-top(1).
// Without left border: inner box is at x=3, y=1 based on padding(1)+prompt(2) and border-top(1).
await act(async () => {
stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`);
});
@@ -2727,6 +2899,37 @@ describe('InputPrompt', () => {
unmount();
});
it('should move cursor on mouse click with plain borders', async () => {
props.config.getUseBackgroundColor = () => false;
props.buffer.text = 'hello world';
props.buffer.lines = ['hello world'];
props.buffer.viewportVisualLines = ['hello world'];
props.buffer.visualToLogicalMap = [[0, 0]];
props.buffer.visualCursor = [0, 11];
props.buffer.visualScrollRow = 0;
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ mouseEventsEnabled: true, uiActions },
);
// Wait for initial render
await waitFor(() => {
expect(stdout.lastFrame()).toContain('hello world');
});
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
await act(async () => {
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
});
await waitFor(() => {
expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith(0, 0);
});
unmount();
});
});
describe('queued message editing', () => {
@@ -2889,7 +3092,8 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
await waitFor(() => expect(stdout.lastFrame()).toContain('!'));
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
@@ -2898,7 +3102,8 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
await waitFor(() => expect(stdout.lastFrame()).toContain('>'));
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
@@ -2907,10 +3112,10 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
await waitFor(() => expect(stdout.lastFrame()).toContain('*'));
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
it('should not show inverted cursor when shell is focused', async () => {
props.isEmbeddedShellFocused = true;
props.focus = false;
@@ -2919,8 +3124,8 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
expect(stdout.lastFrame()).toMatchSnapshot();
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -3022,8 +3227,9 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />,
);
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Image');
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
@@ -3040,8 +3246,9 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />,
);
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('@/path/to/screenshots');
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
});

View File

@@ -6,11 +6,12 @@
import type React from 'react';
import clipboardy from 'clipboardy';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import type { TextBuffer } from './shared/text-buffer.js';
import {
logicalPosToOffset,
@@ -47,6 +48,9 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -141,7 +145,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { mainAreaWidth, activePtyId, history } = useUIState();
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -321,6 +326,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const allMessages = popAllMessages();
if (allMessages) {
buffer.setText(allMessages);
return true;
} else {
// No queued messages, proceed with input history
inputHistory.navigateUp();
@@ -1033,6 +1039,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions;
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
const terminalBg = terminalBackgroundColor || 'black';
// We should fallback to lines if the background color is disabled OR if it is
// enabled but we are in a low color depth terminal where we don't have a safe
// background color to use.
const useLineFallback = useMemo(() => {
if (!useBackgroundColor) {
return true;
}
if (isLowColor) {
return !getSafeLowColorBackground(terminalBg);
}
return false;
}, [useBackgroundColor, isLowColor, terminalBg]);
useEffect(() => {
if (onSuggestionsVisibilityChange) {
onSuggestionsVisibilityChange(shouldShowSuggestions);
@@ -1085,198 +1108,241 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
) : null;
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
return (
<>
{suggestionsPosition === 'above' && suggestionsNode}
<Box
borderStyle="round"
borderColor={
{useLineFallback ? (
<Box
borderStyle="round"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
width={terminalWidth}
flexDirection="row"
alignItems="flex-start"
height={0}
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
? theme.border.focused
: theme.border.default
}
paddingX={1}
width={mainAreaWidth}
flexDirection="row"
alignItems="flex-start"
minHeight={3}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
<Box
flexGrow={1}
flexDirection="row"
paddingX={1}
borderColor={borderColor}
borderStyle={useLineFallback ? 'round' : undefined}
borderTop={false}
borderBottom={false}
borderLeft={!useBackgroundColor}
borderRight={!useBackgroundColor}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>
{placeholder.slice(1)}
</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender
.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
linesToRender
.map((lineText: string, visualIdxInRenderedSet: number) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = [];
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const transformations =
buffer.transformationsByLine[logicalLineIdx] ?? [];
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
transformations,
...(focus && buffer.cursor[0] === logicalLineIdx
? [buffer.cursor[1]]
: []),
);
const startColInTransformed =
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
const visualStartCol = startColInTransformed;
const visualEndCol = visualStartCol + cpLen(lineText);
const segments = parseSegmentsFromTokens(
tokens,
visualStartCol,
visualEndCol,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
const [logicalLineIdx] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const transformations =
buffer.transformationsByLine[logicalLineIdx] ?? [];
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
transformations,
...(focus && buffer.cursor[0] === logicalLineIdx
? [buffer.cursor[1]]
: []),
);
const startColInTransformed =
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
const visualStartCol = startColInTransformed;
const visualEndCol = visualStartCol + cpLen(lineText);
const segments = parseSegmentsFromTokens(
tokens,
visualStartCol,
visualEndCol,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight =
cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
display,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
if (isOnCursorLine) {
const relativeVisualColForHighlight =
cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
display,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
display,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
display,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
display,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
} else {
// Advance the running counter even when not on cursor line
charCount += segLen;
}
charCount = segEnd;
} else {
// Advance the running counter even when not on cursor line
charCount += segLen;
}
const color =
seg.type === 'command' ||
seg.type === 'file' ||
seg.type === 'paste'
? theme.text.accent
: theme.text.primary;
const color =
seg.type === 'command' ||
seg.type === 'file' ||
seg.type === 'paste'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
}
}
const showCursorBeforeGhost =
focus &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
const showCursorBeforeGhost =
focus &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
);
})
.concat(
additionalLines.map((ghostLine, index) => {
const padding = Math.max(
0,
inputWidth - stringWidth(ghostLine),
);
return (
<Text
key={`ghost-line-${index}`}
color={theme.text.secondary}
>
{ghostLine}
{' '.repeat(padding)}
</Text>
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
);
}),
)
)}
})
.concat(
additionalLines.map((ghostLine, index) => {
const padding = Math.max(
0,
inputWidth - stringWidth(ghostLine),
);
return (
<Text
key={`ghost-line-${index}`}
color={theme.text.secondary}
>
{ghostLine}
{' '.repeat(padding)}
</Text>
);
}),
)
)}
</Box>
</Box>
</Box>
</HalfLinePaddedBox>
{useLineFallback ? (
<Box
borderStyle="round"
borderTop={false}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
width={terminalWidth}
flexDirection="row"
alignItems="flex-start"
height={0}
/>
) : null}
{suggestionsPosition === 'below' && suggestionsNode}
</>
);

View File

@@ -129,6 +129,7 @@ export const MainContent = () => {
return (
<ScrollableList
hasFocus={!uiState.isEditorDialogOpen}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}

View File

@@ -43,7 +43,7 @@ const mockSetVimMode = vi.fn();
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
mainAreaWidth: 100, // Fixed width for consistent snapshots
terminalWidth: 100, // Fixed width for consistent snapshots
}),
}));

View File

@@ -39,14 +39,14 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
╭─────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯"
╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool1 Description for tool 1
╰─────────────────────────────────────────────────────────────────────────────
╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool2 Description for tool 2
╰─────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
@@ -83,14 +83,14 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
╭─────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯"
╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool1 Description for tool 1
╰─────────────────────────────────────────────────────────────────────────────
╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool2 Description for tool 2
╰─────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
@@ -127,8 +127,8 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
> Hello Gemini
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hello User!"
`;

View File

@@ -65,9 +65,9 @@ exports[`<AppHeader /> > should render the banner when previewFeatures is disabl
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ This is the default banner
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -86,9 +86,9 @@ exports[`<AppHeader /> > should render the banner with default text 1`] = `
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ This is the default banner
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -107,9 +107,9 @@ exports[`<AppHeader /> > should render the banner with warning text 1`] = `
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ There are capacity issues
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.

View File

@@ -1,11 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;

View File

@@ -1,69 +1,69 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
(r:) Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
..."
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
(r:) Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
(r:) commit
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
(r:) commit
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
> [Image ...reenshot2x.png]
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Image ...reenshot2x.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
> @/path/to/screenshots/screenshot2x.png
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> @/path/to/screenshots/screenshot2x.png
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
> Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
! Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
* Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
> Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserMessage } from './UserMessage.js';
import { describe, it, expect, vi } from 'vitest';
@@ -15,8 +15,9 @@ vi.mock('../../utils/commandUtils.js', () => ({
describe('UserMessage', () => {
it('renders normal user message with correct prefix', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
{ width: 80 },
);
const output = lastFrame();
@@ -24,7 +25,10 @@ describe('UserMessage', () => {
});
it('renders slash command message', () => {
const { lastFrame } = render(<UserMessage text="/help" width={80} />);
const { lastFrame } = renderWithProviders(
<UserMessage text="/help" width={80} />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -32,7 +36,10 @@ describe('UserMessage', () => {
it('renders multiline user message', () => {
const message = 'Line 1\nLine 2';
const { lastFrame } = render(<UserMessage text={message} width={80} />);
const { lastFrame } = renderWithProviders(
<UserMessage text={message} width={80} />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toMatchSnapshot();

View File

@@ -9,6 +9,9 @@ import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserMessageProps {
text: string;
@@ -19,27 +22,39 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
return (
<Box
flexDirection="row"
paddingY={0}
marginY={1}
alignSelf="flex-start"
width={width}
<HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box width={prefixWidth} flexShrink={0}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
<Box
flexDirection="row"
paddingY={0}
marginY={useBackgroundColor ? 0 : 1}
paddingX={useBackgroundColor ? 1 : 0}
alignSelf="flex-start"
width={width}
>
<Box width={prefixWidth} flexShrink={0}>
<Text
color={theme.text.accent}
aria-label={SCREEN_READER_USER_PREFIX}
>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
</HalfLinePaddedBox>
);
};

View File

@@ -7,19 +7,40 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserShellMessageProps {
text: string;
width: number;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
export const UserShellMessage: React.FC<UserShellMessageProps> = ({
text,
width,
}) => {
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={theme.ui.symbol}>$ </Text>
<Text color={theme.text.primary}>{commandToDisplay}</Text>
</Box>
<HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
paddingY={0}
marginY={useBackgroundColor ? 0 : 1}
paddingX={useBackgroundColor ? 1 : 0}
width={width}
>
<Text color={theme.ui.symbol}>$ </Text>
<Text color={theme.text.primary}>{commandToDisplay}</Text>
</Box>
</HalfLinePaddedBox>
);
};

View File

@@ -1,20 +1,20 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserMessage > renders multiline user message 1`] = `
"
> Line 1
Line 2
"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Line 1
Line 2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`UserMessage > renders normal user message with correct prefix 1`] = `
"
> Hello Gemini
"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`UserMessage > renders slash command message 1`] = `
"
> /help
"
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> /help
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;

View File

@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import {
interpolateColor,
resolveColor,
getSafeLowColorBackground,
} from '../../themes/color-utils.js';
import { isLowColorDepth } from '../../utils/terminalUtils.js';
export interface HalfLinePaddedBoxProps {
/**
* The base color to blend with the terminal background.
*/
backgroundBaseColor: string;
/**
* The opacity (0-1) for blending the backgroundBaseColor onto the terminal background.
*/
backgroundOpacity: number;
/**
* Whether to render the solid background color.
*/
useBackgroundColor?: boolean;
children: React.ReactNode;
}
/**
* A container component that renders a solid background with half-line padding
* at the top and bottom using block characters (▀/▄).
*/
export const HalfLinePaddedBox: React.FC<HalfLinePaddedBoxProps> = (props) => {
if (props.useBackgroundColor === false) {
return <>{props.children}</>;
}
return <HalfLinePaddedBoxInternal {...props} />;
};
const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
backgroundBaseColor,
backgroundOpacity,
children,
}) => {
const { terminalWidth, terminalBackgroundColor } = useUIState();
const terminalBg = terminalBackgroundColor || 'black';
const isLowColor = isLowColorDepth();
const backgroundColor = useMemo(() => {
// Interpolated background colors often look bad in 256-color terminals
if (isLowColor) {
return getSafeLowColorBackground(terminalBg);
}
const resolvedBase =
resolveColor(backgroundBaseColor) || backgroundBaseColor;
const resolvedTerminalBg = resolveColor(terminalBg) || terminalBg;
return interpolateColor(
resolvedTerminalBg,
resolvedBase,
backgroundOpacity,
);
}, [backgroundBaseColor, backgroundOpacity, terminalBg, isLowColor]);
if (!backgroundColor) {
return <>{children}</>;
}
return (
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
minHeight={1}
flexShrink={0}
backgroundColor={backgroundColor}
>
<Box width={terminalWidth} flexDirection="row">
<Text backgroundColor={backgroundColor} color={terminalBg}>
{'▀'.repeat(terminalWidth)}
</Text>
</Box>
{children}
<Box width={terminalWidth} flexDirection="row">
<Text color={terminalBg} backgroundColor={backgroundColor}>
{'▄'.repeat(terminalWidth)}
</Text>
</Box>
</Box>
);
};

View File

@@ -374,4 +374,37 @@ describe('ScrollableList Demo Behavior', () => {
});
});
});
describe('Width Prop', () => {
it('should apply the width prop to the container', async () => {
const items = [{ id: '1', title: 'Item 1' }];
let lastFrame: () => string | undefined;
await act(async () => {
const result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box width={100} height={20}>
<ScrollableList
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
width={50}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>,
);
lastFrame = result.lastFrame;
});
await waitFor(() => {
expect(lastFrame()).toContain('Item 1');
});
});
});
});

View File

@@ -37,6 +37,7 @@ type VirtualizedListProps<T> = {
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean;
width?: string | number;
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
@@ -45,7 +46,7 @@ function ScrollableList<T>(
props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>,
) {
const { hasFocus } = props;
const { hasFocus, width } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -236,6 +237,7 @@ function ScrollableList<T>(
flexGrow={1}
flexDirection="column"
overflow="hidden"
width={width}
>
<VirtualizedList
ref={virtualizedListRef}