refactor(cli): keyboard handling and AskUserDialog (#17414)

This commit is contained in:
Jacob Richman
2026-01-27 14:26:00 -08:00
committed by GitHub
parent 3103697ea7
commit b51323b40c
46 changed files with 1220 additions and 385 deletions
@@ -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,
],
);