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
@@ -17,7 +17,9 @@ export const AdminSettingsChangedDialog = () => {
(key) => {
if (keyMatchers[Command.RESTART_APP](key)) {
handleRestart();
return true;
}
return false;
},
{ isActive: true },
);
@@ -42,6 +42,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -105,6 +106,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
actions(stdin);
@@ -123,6 +125,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// Move down to custom option
@@ -157,6 +160,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// Type a character without navigating down
@@ -206,6 +210,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -218,6 +223,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -230,6 +236,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -259,6 +266,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toContain('Which testing framework?');
@@ -299,6 +307,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// Answer first question (should auto-advance)
@@ -365,6 +374,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -392,6 +402,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
writeKey(stdin, '\x1b[C'); // Right arrow
@@ -435,6 +446,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// Navigate directly to Review tab without answering
@@ -469,6 +481,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// Answer only first question
@@ -500,6 +513,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -520,6 +534,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -540,6 +555,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
for (const char of 'abc') {
@@ -573,6 +589,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
@@ -602,6 +619,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
for (const char of 'useAuth') {
@@ -615,9 +633,6 @@ describe('AskUserDialog', () => {
});
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
// Wait, Async question is a CHOICE question, so Left arrow SHOULD work.
// But ChoiceQuestionView also captures editing custom option state?
// No, only if it is FOCUSING the custom option.
await waitFor(() => {
expect(lastFrame()).toContain('useAuth');
@@ -650,6 +665,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
for (const char of 'DataTable') {
@@ -698,6 +714,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
writeKey(stdin, '\r');
@@ -722,6 +739,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={onCancel}
/>,
{ width: 120 },
);
for (const char of 'SomeText') {
@@ -766,6 +784,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// 1. Move to Text Q (Right arrow works for Choice Q)
@@ -823,6 +842,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// Answer Q1 and Q2 sequentialy
+170 -223
View File
@@ -13,7 +13,7 @@ import {
useReducer,
useContext,
} from 'react';
import { Box, Text, useStdout } from 'ink';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
@@ -25,40 +25,30 @@ import { checkExhaustive } from '../../utils/checks.js';
import { TextInput } from './shared/TextInput.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { cpLen } from '../utils/textUtils.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
interface AskUserDialogState {
currentQuestionIndex: number;
answers: { [key: string]: string };
isEditingCustomOption: boolean;
cursorEdge: { left: boolean; right: boolean };
submitted: boolean;
}
type AskUserDialogAction =
| {
type: 'NEXT_QUESTION';
payload: { maxIndex: number };
}
| { type: 'PREV_QUESTION' }
| {
type: 'SET_ANSWER';
payload: {
index?: number;
index: number;
answer: string;
autoAdvance?: boolean;
maxIndex?: number;
};
}
| { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } }
| { type: 'SET_CURSOR_EDGE'; payload: { left: boolean; right: boolean } }
| { type: 'SUBMIT' };
const initialState: AskUserDialogState = {
currentQuestionIndex: 0,
answers: {},
isEditingCustomOption: false,
cursorEdge: { left: true, right: true },
submitted: false,
};
@@ -71,56 +61,22 @@ function askUserDialogReducerLogic(
}
switch (action.type) {
case 'NEXT_QUESTION': {
const { maxIndex } = action.payload;
if (state.currentQuestionIndex < maxIndex) {
return {
...state,
currentQuestionIndex: state.currentQuestionIndex + 1,
isEditingCustomOption: false,
cursorEdge: { left: true, right: true },
};
}
return state;
}
case 'PREV_QUESTION': {
if (state.currentQuestionIndex > 0) {
return {
...state,
currentQuestionIndex: state.currentQuestionIndex - 1,
isEditingCustomOption: false,
cursorEdge: { left: true, right: true },
};
}
return state;
}
case 'SET_ANSWER': {
const { index, answer, autoAdvance, maxIndex } = action.payload;
const targetIndex = index ?? state.currentQuestionIndex;
const { index, answer } = action.payload;
const hasAnswer =
answer !== undefined && answer !== null && answer.trim() !== '';
const newAnswers = { ...state.answers };
if (hasAnswer) {
newAnswers[targetIndex] = answer;
newAnswers[index] = answer;
} else {
delete newAnswers[targetIndex];
delete newAnswers[index];
}
const newState = {
return {
...state,
answers: newAnswers,
};
if (autoAdvance && typeof maxIndex === 'number') {
if (newState.currentQuestionIndex < maxIndex) {
newState.currentQuestionIndex += 1;
newState.isEditingCustomOption = false;
newState.cursorEdge = { left: true, right: true };
}
}
return newState;
}
case 'SET_EDITING_CUSTOM': {
if (state.isEditingCustomOption === action.payload.isEditing) {
@@ -131,16 +87,6 @@ function askUserDialogReducerLogic(
isEditingCustomOption: action.payload.isEditing,
};
}
case 'SET_CURSOR_EDGE': {
const { left, right } = action.payload;
if (state.cursorEdge.left === left && state.cursorEdge.right === right) {
return state;
}
return {
...state,
cursorEdge: { left, right },
};
}
case 'SUBMIT': {
return {
...state,
@@ -198,7 +144,9 @@ const ReviewView: React.FC<ReviewViewProps> = ({
(key: Key) => {
if (keyMatchers[Command.RETURN](key)) {
onSubmit();
return true;
}
return false;
},
{ isActive: true },
);
@@ -235,11 +183,10 @@ const ReviewView: React.FC<ReviewViewProps> = ({
</Text>
</Box>
))}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
</Text>
</Box>
<DialogFooter
primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers"
/>
</Box>
);
};
@@ -251,7 +198,7 @@ interface TextQuestionViewProps {
onAnswer: (answer: string) => void;
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
availableWidth: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -262,18 +209,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
onAnswer,
onSelectionChange,
onEditingCustomOption,
onCursorEdgeChange,
availableWidth,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const uiState = useContext(UIStateContext);
const { stdout } = useStdout();
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
const prefix = '> ';
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
const bufferWidth =
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
const buffer = useTextBuffer({
initialText: initialAnswer,
viewport: { width: terminalWidth - 10, height: 1 },
viewport: { width: Math.max(1, bufferWidth), height: 1 },
singleLine: true,
isValidPath: () => false,
});
@@ -289,32 +237,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
}
}, [textValue, onSelectionChange]);
// Sync cursor edge state with parent - only when it actually changes
const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null);
useEffect(() => {
const isLeft = buffer.cursor[1] === 0;
const isRight = buffer.cursor[1] === cpLen(buffer.lines[0] || '');
if (
!lastEdgeRef.current ||
isLeft !== lastEdgeRef.current.left ||
isRight !== lastEdgeRef.current.right
) {
onCursorEdgeChange?.({ left: isLeft, right: isRight });
lastEdgeRef.current = { left: isLeft, right: isRight };
}
}, [buffer.cursor, buffer.lines, onCursorEdgeChange]);
// Handle Ctrl+C to clear all text
const handleExtraKeys = useCallback(
(key: Key) => {
if (keyMatchers[Command.QUIT](key)) {
buffer.setText('');
return true;
}
return false;
},
[buffer],
);
useKeypress(handleExtraKeys, { isActive: true });
useKeypress(handleExtraKeys, { isActive: true, priority: true });
const handleSubmit = useCallback(
(val: string) => {
@@ -445,7 +380,7 @@ interface ChoiceQuestionViewProps {
onAnswer: (answer: string) => void;
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
availableWidth: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -456,14 +391,33 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
onAnswer,
onSelectionChange,
onEditingCustomOption,
onCursorEdgeChange,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const uiState = useContext(UIStateContext);
const { stdout } = useStdout();
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length;
const radioWidth = 2; // "● "
const numberWidth = numLen + 2; // e.g., "1. "
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
const cursorPadding = 1; // Extra character for cursor at end of line
const outerBoxPadding = 4; // border (2) + paddingX (2)
const horizontalPadding =
outerBoxPadding +
radioWidth +
numberWidth +
checkboxWidth +
checkmarkWidth +
cursorPadding;
const bufferWidth = availableWidth - horizontalPadding;
const questionOptions = useMemo(
() => question.options ?? [],
@@ -537,29 +491,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const customBuffer = useTextBuffer({
initialText: initialCustomText,
viewport: { width: terminalWidth - 20, height: 1 },
viewport: { width: Math.max(1, bufferWidth), height: 1 },
singleLine: true,
isValidPath: () => false,
});
const customOptionText = customBuffer.text;
// Sync cursor edge state with parent - only when it actually changes
const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null);
useEffect(() => {
const isLeft = customBuffer.cursor[1] === 0;
const isRight =
customBuffer.cursor[1] === cpLen(customBuffer.lines[0] || '');
if (
!lastEdgeRef.current ||
isLeft !== lastEdgeRef.current.left ||
isRight !== lastEdgeRef.current.right
) {
onCursorEdgeChange?.({ left: isLeft, right: isRight });
lastEdgeRef.current = { left: isLeft, right: isRight };
}
}, [customBuffer.cursor, customBuffer.lines, onCursorEdgeChange]);
// Helper to build answer string from selections
const buildAnswerString = useCallback(
(
@@ -607,31 +545,51 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
// If focusing custom option, handle Ctrl+C
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
customBuffer.setText('');
return;
return true;
}
// Type-to-jump: if a printable character is typed and not focused, jump to custom
// Don't jump if a navigation or selection key is pressed
if (
keyMatchers[Command.DIALOG_NAVIGATION_UP](key) ||
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) ||
keyMatchers[Command.DIALOG_NEXT](key) ||
keyMatchers[Command.DIALOG_PREV](key) ||
keyMatchers[Command.MOVE_LEFT](key) ||
keyMatchers[Command.MOVE_RIGHT](key) ||
keyMatchers[Command.RETURN](key) ||
keyMatchers[Command.ESCAPE](key) ||
keyMatchers[Command.QUIT](key)
) {
return false;
}
// Check if it's a numeric quick selection key (if numbers are shown)
const isNumeric = /^[0-9]$/.test(key.sequence);
if (isNumeric) {
return false;
}
// Type-to-jump: if printable characters are typed and not focused, jump to custom
const isPrintable =
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.alt &&
key.sequence.charCodeAt(0) >= 32;
(key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32);
const isNumber = /^[0-9]$/.test(key.sequence);
if (isPrintable && !isCustomOptionFocused && !isNumber) {
if (isPrintable && !isCustomOptionFocused) {
dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } });
onEditingCustomOption?.(true);
// We can't easily inject the first key into useTextBuffer's internal state
// but TextInput will handle subsequent keys once it's focused.
// For IME or multi-char sequences, we want to capture the whole thing.
// If it's a single char, we start the buffer with it.
customBuffer.setText(key.sequence);
return true;
}
return false;
},
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
);
useKeypress(handleExtraKeys, { isActive: true });
useKeypress(handleExtraKeys, { isActive: true, priority: true });
const selectionItems = useMemo((): Array<SelectionListItem<OptionItem>> => {
const list: Array<SelectionListItem<OptionItem>> = questionOptions.map(
@@ -841,11 +799,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
);
};
/**
* A dialog component for asking the user a series of questions.
* Supports multiple question types (text, choice, yes/no, multi-select),
* navigation between questions, and a final review step.
*/
export const AskUserDialog: React.FC<AskUserDialogProps> = ({
questions,
onSubmit,
@@ -853,30 +806,29 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onActiveTextInputChange,
}) => {
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const {
currentQuestionIndex,
answers,
isEditingCustomOption,
cursorEdge,
submitted,
} = state;
const { answers, isEditingCustomOption, submitted } = state;
// Use refs for synchronous checks to prevent race conditions in handleCancel
const isEditingCustomOptionRef = useRef(false);
isEditingCustomOptionRef.current = isEditingCustomOption;
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const reviewTabIndex = questions.length;
const tabCount =
questions.length > 1 ? questions.length + 1 : questions.length;
const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({
tabCount,
isActive: !submitted && questions.length > 1,
enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually
enableTabKey: false, // We'll handle tab manually to match existing behavior
});
const currentQuestionIndex = currentIndex;
const handleEditingCustomOption = useCallback((isEditing: boolean) => {
dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } });
}, []);
const handleCursorEdgeChange = useCallback(
(edge: { left: boolean; right: boolean }) => {
dispatch({ type: 'SET_CURSOR_EDGE', payload: edge });
},
[],
);
// Sync isEditingCustomOption state with parent for global keypress handling
useEffect(() => {
onActiveTextInputChange?.(isEditingCustomOption);
return () => {
@@ -884,70 +836,58 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
};
}, [isEditingCustomOption, onActiveTextInputChange]);
// Handle Escape or Ctrl+C to cancel (but not Ctrl+C when editing custom option)
const handleCancel = useCallback(
(key: Key) => {
if (submitted) return;
if (submitted) return false;
if (keyMatchers[Command.ESCAPE](key)) {
onCancel();
} else if (
keyMatchers[Command.QUIT](key) &&
!isEditingCustomOptionRef.current
) {
return true;
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
onCancel();
return true;
}
return false;
},
[onCancel, submitted],
[onCancel, submitted, isEditingCustomOption],
);
useKeypress(handleCancel, {
isActive: !submitted,
});
// Review tab is at index questions.length (after all questions)
const reviewTabIndex = questions.length;
const isOnReviewTab = currentQuestionIndex === reviewTabIndex;
// Bidirectional navigation between questions using custom useKeypress for consistency
const handleNavigation = useCallback(
(key: Key) => {
if (submitted) return;
if (submitted || questions.length <= 1) return false;
const isTab = key.name === 'tab';
const isShiftTab = isTab && key.shift;
const isPlainTab = isTab && !key.shift;
const isNextKey = keyMatchers[Command.DIALOG_NEXT](key);
const isPrevKey = keyMatchers[Command.DIALOG_PREV](key);
const isRight = key.name === 'right' && !key.ctrl && !key.alt;
const isLeft = key.name === 'left' && !key.ctrl && !key.alt;
const isRight = keyMatchers[Command.MOVE_RIGHT](key);
const isLeft = keyMatchers[Command.MOVE_LEFT](key);
// Tab always works. Arrows work if NOT editing OR if at the corresponding edge.
const shouldGoNext =
isPlainTab || (isRight && (!isEditingCustomOption || cursorEdge.right));
const shouldGoPrev =
isShiftTab || (isLeft && (!isEditingCustomOption || cursorEdge.left));
// Tab keys always trigger navigation.
// Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge).
const shouldGoNext = isNextKey || isRight;
const shouldGoPrev = isPrevKey || isLeft;
if (shouldGoNext) {
// Allow navigation up to Review tab for multi-question flows
const maxIndex =
questions.length > 1 ? reviewTabIndex : questions.length - 1;
dispatch({
type: 'NEXT_QUESTION',
payload: { maxIndex },
});
goToNextTab();
return true;
} else if (shouldGoPrev) {
dispatch({
type: 'PREV_QUESTION',
});
goToPrevTab();
return true;
}
return false;
},
[isEditingCustomOption, cursorEdge, questions, reviewTabIndex, submitted],
[questions.length, submitted, goToNextTab, goToPrevTab],
);
useKeypress(handleNavigation, {
isActive: questions.length > 1 && !submitted,
});
// Effect to trigger submission when state.submitted becomes true
useEffect(() => {
if (submitted) {
onSubmit(answers);
@@ -958,24 +898,23 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
(answer: string) => {
if (submitted) return;
const reviewTabIndex = questions.length;
dispatch({
type: 'SET_ANSWER',
payload: {
index: currentQuestionIndex,
answer,
autoAdvance: questions.length > 1,
maxIndex: reviewTabIndex,
},
});
if (questions.length === 1) {
if (questions.length > 1) {
goToNextTab();
} else {
dispatch({ type: 'SUBMIT' });
}
},
[questions.length, submitted],
[currentQuestionIndex, questions.length, submitted, goToNextTab],
);
// Submit from Review tab
const handleReviewSubmit = useCallback(() => {
if (submitted) return;
dispatch({ type: 'SUBMIT' });
@@ -987,12 +926,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
dispatch({
type: 'SET_ANSWER',
payload: {
index: currentQuestionIndex,
answer,
autoAdvance: false,
},
});
},
[submitted],
[submitted, currentQuestionIndex],
);
const answeredIndices = useMemo(
@@ -1002,7 +941,6 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
const currentQuestion = questions[currentQuestionIndex];
// For yesno type, generate Yes/No options and force single-select
const effectiveQuestion = useMemo(() => {
if (currentQuestion?.type === 'yesno') {
return {
@@ -1017,13 +955,11 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
return currentQuestion;
}, [currentQuestion]);
// Build tabs array for TabHeader
const tabs = useMemo((): Tab[] => {
const questionTabs: Tab[] = questions.map((q, i) => ({
key: String(i),
header: q.header,
}));
// Add review tab when there are multiple questions
if (questions.length > 1) {
questionTabs.push({
key: 'review',
@@ -1043,63 +979,74 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
/>
) : null;
// Render Review tab when on it
if (isOnReviewTab) {
return (
<ReviewView
questions={questions}
answers={answers}
onSubmit={handleReviewSubmit}
progressHeader={progressHeader}
/>
<Box aria-label="Review your answers">
<ReviewView
questions={questions}
answers={answers}
onSubmit={handleReviewSubmit}
progressHeader={progressHeader}
/>
</Box>
);
}
// Safeguard for invalid question index
if (!currentQuestion) return null;
const keyboardHints = (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{currentQuestion.type === 'text' || isEditingCustomOption
? questions.length > 1
? 'Enter to submit · Tab/Shift+Tab to switch questions · Esc to cancel'
: 'Enter to submit · Esc to cancel'
: questions.length > 1
? 'Enter to select · ←/→ to switch questions · Esc to cancel'
: 'Enter to select · ↑/↓ to navigate · Esc to cancel'}
</Text>
</Box>
<DialogFooter
primaryAction={
currentQuestion.type === 'text' || isEditingCustomOption
? 'Enter to submit'
: 'Enter to select'
}
navigationActions={
questions.length > 1
? currentQuestion.type === 'text' || isEditingCustomOption
? 'Tab/Shift+Tab to switch questions'
: '←/→ to switch questions'
: currentQuestion.type === 'text' || isEditingCustomOption
? undefined
: '↑/↓ to navigate'
}
/>
);
// Render text-type or choice-type question view
if (currentQuestion.type === 'text') {
return (
const questionView =
currentQuestion.type === 'text' ? (
<TextQuestionView
key={currentQuestionIndex}
question={currentQuestion}
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
onCursorEdgeChange={handleCursorEdgeChange}
availableWidth={availableWidth}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
/>
) : (
<ChoiceQuestionView
key={currentQuestionIndex}
question={effectiveQuestion}
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
/>
);
}
return (
<ChoiceQuestionView
key={currentQuestionIndex}
question={effectiveQuestion}
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
onCursorEdgeChange={handleCursorEdgeChange}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
/>
<Box
flexDirection="column"
width={availableWidth}
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
>
{questionView}
</Box>
);
};
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import type { Question } from '@google/gemini-cli-core';
describe('Key Bubbling Regression', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const choiceQuestion: Question[] = [
{
question: 'Choice Q?',
header: 'Choice',
options: [
{ label: 'Option 1', description: '' },
{ label: 'Option 2', description: '' },
],
multiSelect: false,
},
];
it('does not navigate when pressing "j" or "k" in a focused text input', async () => {
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={choiceQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{ width: 120 },
);
// 1. Move down to "Enter a custom value" (3rd item)
act(() => {
stdin.write('\x1b[B'); // Down arrow to Option 2
});
act(() => {
stdin.write('\x1b[B'); // Down arrow to Custom
});
await waitFor(() => {
expect(lastFrame()).toContain('Enter a custom value');
});
// 2. Type "j"
act(() => {
stdin.write('j');
});
await waitFor(() => {
expect(lastFrame()).toContain('j');
// Verify we are still focusing the custom option (3rd item in list)
expect(lastFrame()).toMatch(/● 3\.\s+j/);
});
// 3. Type "k"
act(() => {
stdin.write('k');
});
await waitFor(() => {
expect(lastFrame()).toContain('jk');
expect(lastFrame()).toMatch(/● 3\.\s+jk/);
});
});
});
@@ -50,10 +50,13 @@ export function EditorSettingsDialog({
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
return true;
}
if (key.name === 'escape') {
onExit();
return true;
}
return false;
},
{ isActive: true },
);
@@ -59,7 +59,9 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
(key) => {
if (key.name === 'escape') {
handleExit();
return true;
}
return false;
},
{ isActive: !isRestarting },
);
@@ -21,7 +21,9 @@ export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
if (key.name === 'r' || key.name === 'R') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
relaunchApp();
return true;
}
return false;
},
{ isActive: true },
);
+51 -51
View File
@@ -372,7 +372,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
return;
}
}
@@ -469,7 +468,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && key.name !== 'paste') {
return;
return false;
}
if (key.name === 'paste') {
@@ -498,11 +497,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
return;
return true;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
return true;
}
// Reset ESC count and hide prompt on any non-ESC key
@@ -519,7 +518,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
return true;
}
if (keyMatchers[Command.ESCAPE](key)) {
@@ -544,27 +543,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
return true;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
return true;
}
// Handle double ESC
@@ -577,7 +576,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
return;
return true;
}
// Second ESC
@@ -585,26 +584,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
return true;
} else if (history.length > 0) {
onSubmit('/rewind');
return;
return true;
}
coreEvents.emitFeedback('info', 'Nothing to rewind to');
return;
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return;
return true;
}
if (reverseSearchActive || commandSearchActive) {
@@ -629,29 +628,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
return true;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
return true;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
return true;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
return true;
}
}
@@ -663,7 +662,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(textToSubmit);
resetState();
setActive(false);
return;
return true;
}
// Prevent up/down from falling through to regular history navigation
@@ -671,7 +670,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
return true;
}
}
@@ -683,7 +682,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
) {
handleSubmit(buffer.text);
return;
return true;
}
if (completion.showSuggestions) {
@@ -691,12 +690,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
}
@@ -725,7 +724,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
return;
return true;
}
} else if (!isArgumentCompletion) {
// Existing logic for command name completion
@@ -745,7 +744,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
return;
return true;
}
}
}
@@ -756,7 +755,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
return true;
}
}
@@ -767,7 +766,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
return;
return true;
}
if (!shellModeActive) {
@@ -775,22 +774,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
// Check for queued messages first when input is empty
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
if (tryLoadQueuedMessages()) {
return;
return true;
}
// Only navigate history if popAllMessages doesn't exist
inputHistory.navigateUp();
return;
return true;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
return true;
}
// Handle arrow-up/down for history on single-line or at edges
if (
@@ -801,11 +800,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Check for queued messages first when input is empty
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
if (tryLoadQueuedMessages()) {
return;
return true;
}
// Only navigate history if popAllMessages doesn't exist
inputHistory.navigateUp();
return;
return true;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
@@ -813,19 +812,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
return true;
}
} else {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
return true;
}
}
@@ -840,7 +839,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// get some feedback that their keypress was handled rather than
// wondering why it was completely ignored.
buffer.newline();
return;
return true;
}
const [row, col] = buffer.cursor;
@@ -853,23 +852,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmit(buffer.text);
}
}
return;
return true;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
return true;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
return true;
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
return true;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
@@ -877,36 +876,36 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.setText('');
resetCompletionState();
}
return;
return false;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
return true;
}
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
return true;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
return true;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
buffer.openInExternalEditor();
return;
return true;
}
// Ctrl+V for clipboard paste
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleClipboardPaste();
return;
return true;
}
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
@@ -914,11 +913,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (activePtyId) {
setEmbeddedShellFocused(true);
}
return;
return true;
}
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
const handled = buffer.handleInput(key);
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
@@ -932,6 +931,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
return handled;
},
[
focus,
@@ -28,7 +28,9 @@ export const LogoutConfirmationDialog: React.FC<
(key) => {
if (key.name === 'escape') {
onSelect(LogoutChoice.EXIT);
return true;
}
return false;
},
{ isActive: true },
);
@@ -27,7 +27,9 @@ export function LoopDetectionConfirmation({
onComplete({
userSelection: 'keep',
});
return true;
}
return false;
},
{ isActive: true },
);
@@ -62,10 +62,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
} else {
onClose();
}
return true;
}
if (key.name === 'tab') {
setPersistMode((prev) => !prev);
return true;
}
return false;
},
{ isActive: true },
);
@@ -72,7 +72,9 @@ export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
if (key.name === 'escape') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleCancel();
return true;
}
return false;
},
{ isActive: !submitted },
);
@@ -66,6 +66,7 @@ export function PermissionsModifyTrustDialog({
(key) => {
if (key.name === 'escape') {
onExit();
return true;
}
if (needsRestart && key.name === 'r') {
const success = commitTrustLevelChange();
@@ -75,7 +76,9 @@ export function PermissionsModifyTrustDialog({
} else {
onExit();
}
return true;
}
return false;
},
{ isActive: true },
);
@@ -62,7 +62,9 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onConfirm(RewindOutcome.Cancel);
return true;
}
return false;
},
{ isActive: true },
);
@@ -90,7 +90,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return;
return true;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
@@ -98,12 +98,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
return true;
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
return true;
}
}
return false;
},
{ isActive: true },
);
@@ -775,10 +775,12 @@ export const useSessionBrowserInput = (
state.setSearchQuery('');
state.setActiveIndex(0);
state.setScrollOffset(0);
return true;
} else if (key.name === 'backspace') {
state.setSearchQuery((prev) => prev.slice(0, -1));
state.setActiveIndex(0);
state.setScrollOffset(0);
return true;
} else if (
key.sequence &&
key.sequence.length === 1 &&
@@ -789,6 +791,7 @@ export const useSessionBrowserInput = (
state.setSearchQuery((prev) => prev + key.sequence);
state.setActiveIndex(0);
state.setScrollOffset(0);
return true;
}
} else {
// Navigation mode input handling. We're keeping the letter-based controls for non-search
@@ -796,27 +799,33 @@ export const useSessionBrowserInput = (
if (key.sequence === 'g') {
state.setActiveIndex(0);
state.setScrollOffset(0);
return true;
} else if (key.sequence === 'G') {
state.setActiveIndex(state.totalSessions - 1);
state.setScrollOffset(
Math.max(0, state.totalSessions - SESSIONS_PER_PAGE),
);
return true;
}
// Sorting controls.
else if (key.sequence === 's') {
cycleSortOrder();
return true;
} else if (key.sequence === 'r') {
state.setSortReverse(!state.sortReverse);
return true;
}
// Searching and exit controls.
else if (key.sequence === '/') {
state.setIsSearchMode(true);
return true;
} else if (
key.sequence === 'q' ||
key.sequence === 'Q' ||
key.name === 'escape'
) {
onExit();
return true;
}
// Delete session control.
else if (key.sequence === 'x' || key.sequence === 'X') {
@@ -846,12 +855,15 @@ export const useSessionBrowserInput = (
);
});
}
return true;
}
// less-like u/d controls.
else if (key.sequence === 'u') {
moveSelection(-Math.round(SESSIONS_PER_PAGE / 2));
return true;
} else if (key.sequence === 'd') {
moveSelection(Math.round(SESSIONS_PER_PAGE / 2));
return true;
}
}
@@ -866,15 +878,21 @@ export const useSessionBrowserInput = (
if (!selectedSession.isCurrentSession) {
onResumeSession(selectedSession);
}
return true;
} else if (key.name === 'up') {
moveSelection(-1);
return true;
} else if (key.name === 'down') {
moveSelection(1);
return true;
} else if (key.name === 'pageup') {
moveSelection(-SESSIONS_PER_PAGE);
return true;
} else if (key.name === 'pagedown') {
moveSelection(SESSIONS_PER_PAGE);
return true;
}
return false;
},
{ isActive: true },
);
@@ -201,10 +201,13 @@ export function ThemeDialog({
(key) => {
if (key.name === 'tab') {
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
return true;
}
if (key.name === 'escape') {
onCancel();
return true;
}
return false;
},
{ isActive: true },
);
@@ -53,10 +53,13 @@ export function ValidationDialog({
(key) => {
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
onChoice('cancel');
return true;
} else if (state === 'waiting' && keyMatchers[Command.RETURN](key)) {
// User confirmed verification is complete - transition to 'complete' state
setState('complete');
return true;
}
return false;
},
{ isActive: state !== 'complete' },
);
@@ -34,18 +34,18 @@ exports[`AskUserDialog > Text type questions > shows default placeholder when no
`;
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ← □ Tests │ □ Docs │ ≡ Review →
│ Review your answers:
│ ⚠ You have 2 unanswered questions
│ Tests → (not answered)
│ Docs → (not answered)
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ Tests │ □ Docs │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ Tests → (not answered) │
│ Docs → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > hides progress header for single question 1`] = `
@@ -123,16 +123,16 @@ exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ← □ License │ □ README │ ≡ Review →
│ Review your answers:
│ ⚠ You have 2 unanswered questions
│ License → (not answered)
│ README → (not answered)
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ License │ □ README │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ License → (not answered) │
│ README → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
`;
@@ -413,3 +413,371 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render accessibility settings enabled correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render all boolean settings disabled correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false* │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false* │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render default state correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render file filtering settings configured correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render focused on scope selector correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ Preview Features (e.g., models) false │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ > Apply To │
│ ● 1. User Settings │
│ 2. Workspace Settings │
│ 3. System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render mixed boolean and number settings correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode true* │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render tools and security settings correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render various boolean settings enabled correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) true* │
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode true* │
│ Enable Vim keybindings │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
│ Hide Window Title false │
│ Hide the window title bar │
│ │
│ ▼ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -75,10 +75,12 @@ export const ToolConfirmationMessage: React.FC<
useKeypress(
(key) => {
if (!isFocused) return;
if (!isFocused) return false;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
}
return false;
},
{ isActive: isFocused },
);
@@ -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,
],
);