mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
refactor(cli): keyboard handling and AskUserDialog (#17414)
This commit is contained in:
@@ -32,6 +32,7 @@ export interface BaseSelectionListProps<
|
||||
maxItemsToShow?: number;
|
||||
wrapAround?: boolean;
|
||||
focusKey?: string;
|
||||
priority?: boolean;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ export function BaseSelectionList<
|
||||
maxItemsToShow = 10,
|
||||
wrapAround = true,
|
||||
focusKey,
|
||||
priority,
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
@@ -74,6 +76,7 @@ export function BaseSelectionList<
|
||||
showNumbers,
|
||||
wrapAround,
|
||||
focusKey,
|
||||
priority,
|
||||
});
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
@@ -336,7 +336,7 @@ export function BaseSettingsDialog({
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
@@ -346,7 +346,7 @@ export function BaseSettingsDialog({
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter - toggle or start edit
|
||||
@@ -359,19 +359,19 @@ export function BaseSettingsDialog({
|
||||
const initialValue = rawVal !== undefined ? String(rawVal) : '';
|
||||
startEditing(currentItem.key, initialValue);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+L - clear/reset to default (using only Ctrl+L to avoid Ctrl+C exit conflict)
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key) && currentItem) {
|
||||
onItemClear(currentItem.key, currentItem);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Number keys for quick edit on number fields
|
||||
if (currentItem?.type === 'number' && /^[0-9]$/.test(key.sequence)) {
|
||||
startEditing(currentItem.key, key.sequence);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,8 @@ export function BaseSettingsDialog({
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
@@ -565,6 +567,7 @@ export function BaseSettingsDialog({
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
showNumbers={focusSection === 'scope'}
|
||||
priority={focusSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export interface DialogFooterProps {
|
||||
/** The main shortcut (e.g., "Enter to submit") */
|
||||
primaryAction: string;
|
||||
/** Secondary navigation shortcuts (e.g., "Tab/Shift+Tab to switch questions") */
|
||||
navigationActions?: string;
|
||||
/** Exit shortcut (defaults to "Esc to cancel") */
|
||||
cancelAction?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared footer component for dialogs to ensure consistent styling and formatting
|
||||
* of keyboard shortcuts and help text.
|
||||
*/
|
||||
export const DialogFooter: React.FC<DialogFooterProps> = ({
|
||||
primaryAction,
|
||||
navigationActions,
|
||||
cancelAction = 'Esc to cancel',
|
||||
}) => {
|
||||
const parts = [primaryAction];
|
||||
if (navigationActions) {
|
||||
parts.push(navigationActions);
|
||||
}
|
||||
parts.push(cancelAction);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{parts.join(' · ')}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -44,6 +44,8 @@ export interface RadioButtonSelectProps<T> {
|
||||
maxItemsToShow?: number;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
/** Whether the hook should have priority over normal subscribers. */
|
||||
priority?: boolean;
|
||||
/** Optional custom renderer for items. */
|
||||
renderItem?: (
|
||||
item: RadioSelectItem<T>,
|
||||
@@ -66,6 +68,7 @@ export function RadioButtonSelect<T>({
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
priority,
|
||||
renderItem,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
return (
|
||||
@@ -78,6 +81,7 @@ export function RadioButtonSelect<T>({
|
||||
showNumbers={showNumbers}
|
||||
showScrollArrows={showScrollArrows}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
priority={priority}
|
||||
renderItem={
|
||||
renderItem ||
|
||||
((item, { titleColor }) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('TabHeader', () => {
|
||||
expect(frame).toContain('Tab 1');
|
||||
expect(frame).toContain('Tab 2');
|
||||
expect(frame).toContain('Tab 3');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders separators between tabs', () => {
|
||||
@@ -44,6 +45,7 @@ describe('TabHeader', () => {
|
||||
// Should have 2 separators for 3 tabs
|
||||
const separatorCount = (frame?.match(/│/g) || []).length;
|
||||
expect(separatorCount).toBe(2);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +57,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('←');
|
||||
expect(frame).toContain('→');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides arrows when showArrows is false', () => {
|
||||
@@ -64,6 +67,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('←');
|
||||
expect(frame).not.toContain('→');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +79,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
// Default uncompleted icon is □
|
||||
expect(frame).toContain('□');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides status icons when showStatusIcons is false', () => {
|
||||
@@ -84,6 +89,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('□');
|
||||
expect(frame).not.toContain('✓');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows checkmark for completed tabs', () => {
|
||||
@@ -100,6 +106,7 @@ describe('TabHeader', () => {
|
||||
const boxCount = (frame?.match(/□/g) || []).length;
|
||||
expect(checkmarkCount).toBe(2);
|
||||
expect(boxCount).toBe(1);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows special icon for special tabs', () => {
|
||||
@@ -113,6 +120,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
// Special tab shows ≡ icon
|
||||
expect(frame).toContain('≡');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('uses tab statusIcon when provided', () => {
|
||||
@@ -125,6 +133,7 @@ describe('TabHeader', () => {
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('★');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('uses custom renderStatusIcon when provided', () => {
|
||||
@@ -139,6 +148,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
const bulletCount = (frame?.match(/•/g) || []).length;
|
||||
expect(bulletCount).toBe(3);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('falls back to default when renderStatusIcon returns undefined', () => {
|
||||
@@ -152,6 +162,7 @@ describe('TabHeader', () => {
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('□');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,16 +81,16 @@ export function TabHeader({
|
||||
if (tab.statusIcon) return tab.statusIcon;
|
||||
|
||||
// Default icons
|
||||
if (tab.isSpecial) return '\u2261'; // ≡
|
||||
return isCompleted ? '\u2713' : '\u25A1'; // ✓ or □
|
||||
if (tab.isSpecial) return '≡';
|
||||
return isCompleted ? '✓' : '□';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{showArrows && <Text color={theme.text.secondary}>{'\u2190 '}</Text>}
|
||||
<Box flexDirection="row" marginBottom={1} aria-role="tablist">
|
||||
{showArrows && <Text color={theme.text.secondary}>{'← '}</Text>}
|
||||
{tabs.map((tab, i) => (
|
||||
<React.Fragment key={tab.key}>
|
||||
{i > 0 && <Text color={theme.text.secondary}>{' \u2502 '}</Text>}
|
||||
{i > 0 && <Text color={theme.text.secondary}>{' │ '}</Text>}
|
||||
{showStatusIcons && (
|
||||
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
|
||||
)}
|
||||
@@ -99,12 +99,13 @@ export function TabHeader({
|
||||
i === currentIndex ? theme.text.accent : theme.text.secondary
|
||||
}
|
||||
bold={i === currentIndex}
|
||||
aria-current={i === currentIndex ? 'step' : undefined}
|
||||
>
|
||||
{tab.header}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{showArrows && <Text color={theme.text.secondary}>{' \u2192'}</Text>}
|
||||
{showArrows && <Text color={theme.text.secondary}>{' →'}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,22 +40,23 @@ export function TextInput({
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape') {
|
||||
onCancel?.();
|
||||
return;
|
||||
if (key.name === 'escape' && onCancel) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.name === 'return') {
|
||||
onSubmit?.(text);
|
||||
return;
|
||||
if (key.name === 'return' && onSubmit) {
|
||||
onSubmit(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleInput(key);
|
||||
const handled = handleInput(key);
|
||||
return handled;
|
||||
},
|
||||
[handleInput, onCancel, onSubmit, text],
|
||||
);
|
||||
|
||||
useKeypress(handleKeyPress, { isActive: focus });
|
||||
useKeypress(handleKeyPress, { isActive: focus, priority: true });
|
||||
|
||||
const showPlaceholder = text.length === 0 && placeholder;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TabHeader > arrows > hides arrows when showArrows is false 1`] = `
|
||||
"□ Tab 1 │ □ Tab 2 │ □ Tab 3
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > arrows > shows arrows by default 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > rendering > renders all tab headers 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > rendering > renders separators between tabs 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > falls back to default when renderStatusIcon returns undefined 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > hides status icons when showStatusIcons is false 1`] = `
|
||||
"← Tab 1 │ Tab 2 │ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > shows checkmark for completed tabs 1`] = `
|
||||
"← ✓ Tab 1 │ □ Tab 2 │ ✓ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > shows special icon for special tabs 1`] = `
|
||||
"← □ Tab 1 │ ≡ Review →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > shows status icons by default 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > uses custom renderStatusIcon when provided 1`] = `
|
||||
"← • Tab 1 │ • Tab 2 │ • Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > uses tab statusIcon when provided 1`] = `
|
||||
"← ★ Tab 1 │ □ Tab 2 →
|
||||
"
|
||||
`;
|
||||
@@ -2867,27 +2867,98 @@ export function useTextBuffer({
|
||||
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): void => {
|
||||
(key: Key): boolean => {
|
||||
const { sequence: input } = key;
|
||||
|
||||
if (key.name === 'paste') insert(input, { paste: true });
|
||||
else if (keyMatchers[Command.RETURN](key)) newline();
|
||||
else if (keyMatchers[Command.NEWLINE](key)) newline();
|
||||
else if (keyMatchers[Command.MOVE_LEFT](key)) move('left');
|
||||
else if (keyMatchers[Command.MOVE_RIGHT](key)) move('right');
|
||||
else if (keyMatchers[Command.MOVE_UP](key)) move('up');
|
||||
else if (keyMatchers[Command.MOVE_DOWN](key)) move('down');
|
||||
else if (keyMatchers[Command.MOVE_WORD_LEFT](key)) move('wordLeft');
|
||||
else if (keyMatchers[Command.MOVE_WORD_RIGHT](key)) move('wordRight');
|
||||
else if (keyMatchers[Command.HOME](key)) move('home');
|
||||
else if (keyMatchers[Command.END](key)) move('end');
|
||||
else if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) deleteWordLeft();
|
||||
else if (keyMatchers[Command.DELETE_WORD_FORWARD](key)) deleteWordRight();
|
||||
else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) backspace();
|
||||
else if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) del();
|
||||
else if (keyMatchers[Command.UNDO](key)) undo();
|
||||
else if (keyMatchers[Command.REDO](key)) redo();
|
||||
else if (key.insertable) insert(input, { paste: false });
|
||||
if (key.name === 'paste') {
|
||||
insert(input, { paste: true });
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
if (singleLine) {
|
||||
return false;
|
||||
}
|
||||
newline();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
if (singleLine) {
|
||||
return false;
|
||||
}
|
||||
newline();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
if (cursorRow === 0 && cursorCol === 0) return false;
|
||||
move('left');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
const lastLineIdx = lines.length - 1;
|
||||
if (
|
||||
cursorRow === lastLineIdx &&
|
||||
cursorCol === cpLen(lines[lastLineIdx] ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
move('right');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_UP](key)) {
|
||||
if (cursorRow === 0) return false;
|
||||
move('up');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_DOWN](key)) {
|
||||
if (cursorRow === lines.length - 1) return false;
|
||||
move('down');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_WORD_LEFT](key)) {
|
||||
move('wordLeft');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_WORD_RIGHT](key)) {
|
||||
move('wordRight');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
move('home');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
move('end');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
deleteWordLeft();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_WORD_FORWARD](key)) {
|
||||
deleteWordRight();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
|
||||
backspace();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||
del();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.UNDO](key)) {
|
||||
undo();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.REDO](key)) {
|
||||
redo();
|
||||
return true;
|
||||
}
|
||||
if (key.insertable) {
|
||||
insert(input, { paste: false });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[
|
||||
newline,
|
||||
@@ -2899,6 +2970,10 @@ export function useTextBuffer({
|
||||
insert,
|
||||
undo,
|
||||
redo,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
lines,
|
||||
singleLine,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user