diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index 8e224ca1ce..aa2d8200fe 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -66,12 +66,14 @@ available combinations.
#### Navigation
-| Action | Keys |
-| -------------------------------- | ------------------------------------------- |
-| Move selection up in lists. | `Up Arrow (no Shift)` |
-| Move selection down in lists. | `Down Arrow (no Shift)` |
-| Move up within dialog options. | `Up Arrow (no Shift)`
`K (no Shift)` |
-| Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` |
+| Action | Keys |
+| -------------------------------------------------- | ------------------------------------------- |
+| Move selection up in lists. | `Up Arrow (no Shift)` |
+| Move selection down in lists. | `Down Arrow (no Shift)` |
+| Move up within dialog options. | `Up Arrow (no Shift)`
`K (no Shift)` |
+| Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` |
+| Move to the next item or question in a dialog. | `Tab (no Shift)` |
+| Move to the previous item or question in a dialog. | `Shift + Tab` |
#### Suggestions & Completions
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 3a8b54d683..86b3580536 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -56,6 +56,8 @@ export enum Command {
NAVIGATION_DOWN = 'nav.down',
DIALOG_NAVIGATION_UP = 'nav.dialog.up',
DIALOG_NAVIGATION_DOWN = 'nav.dialog.down',
+ DIALOG_NEXT = 'nav.dialog.next',
+ DIALOG_PREV = 'nav.dialog.previous',
// Suggestions & Completions
ACCEPT_SUGGESTION = 'suggest.accept',
@@ -206,6 +208,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'down', shift: false },
{ key: 'j', shift: false },
],
+ [Command.DIALOG_NEXT]: [{ key: 'tab', shift: false }],
+ [Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
@@ -332,6 +336,8 @@ export const commandCategories: readonly CommandCategory[] = [
Command.NAVIGATION_DOWN,
Command.DIALOG_NAVIGATION_UP,
Command.DIALOG_NAVIGATION_DOWN,
+ Command.DIALOG_NEXT,
+ Command.DIALOG_PREV,
],
},
{
@@ -426,6 +432,8 @@ export const commandDescriptions: Readonly> = {
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
+ [Command.DIALOG_NEXT]: 'Move to the next item or question in a dialog.',
+ [Command.DIALOG_PREV]: 'Move to the previous item or question in a dialog.',
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 333c5b4cc3..507837be87 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -1401,7 +1401,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCopyModeEnabled(false);
enableMouseEvents();
// We don't want to process any other keys if we're in copy mode.
- return;
+ return true;
}
// Debug log keystrokes if enabled
@@ -1412,7 +1412,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true);
disableMouseEvents();
- return;
+ return true;
}
if (keyMatchers[Command.QUIT](key)) {
@@ -1425,13 +1425,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
cancelOngoingRequest?.();
setCtrlCPressCount((prev) => prev + 1);
- return;
+ return true;
} else if (keyMatchers[Command.EXIT](key)) {
if (buffer.text.length > 0) {
- return;
+ return false;
}
setCtrlDPressCount((prev) => prev + 1);
- return;
+ return true;
}
let enteringConstrainHeightMode = false;
@@ -1442,8 +1442,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
setShowErrorDetails((prev) => !prev);
+ return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev);
+ return true;
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) {
setRenderMarkdown((prev) => {
const newValue = !prev;
@@ -1451,6 +1453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic();
return newValue;
});
+ return true;
} else if (
keyMatchers[Command.SHOW_IDE_CONTEXT_DETAIL](key) &&
config.getIdeMode() &&
@@ -1458,11 +1461,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/ide status');
+ return true;
} else if (
keyMatchers[Command.SHOW_MORE_LINES](key) &&
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
+ return true;
} else if (
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
activePtyId &&
@@ -1471,7 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (key.name === 'tab' && key.shift) {
// Always change focus
setEmbeddedShellFocused(false);
- return;
+ return true;
}
const now = Date.now();
@@ -1491,10 +1496,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setEmbeddedShellFocused(false);
}, 100);
- return;
+ return true;
}
handleWarning('Press Shift+Tab to focus out.');
+ return true;
}
+ return false;
},
[
constrainHeight,
diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx
index 2dfa6a263f..409a6469f6 100644
--- a/packages/cli/src/ui/IdeIntegrationNudge.tsx
+++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx
@@ -32,7 +32,9 @@ export function IdeIntegrationNudge({
userSelection: 'no',
isExtensionPreInstalled: false,
});
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx
index ddcf301268..551cc68634 100644
--- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx
+++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx
@@ -5,6 +5,7 @@
*/
import { render } from '../../test-utils/render.js';
+import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ApiAuthDialog } from './ApiAuthDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -132,17 +133,20 @@ describe('ApiAuthDialog', () => {
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
render();
- // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
+ // Call 0 is ApiAuthDialog (isActive: true)
+ // Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
- await keypressHandler({
+ keypressHandler({
name: 'c',
shift: false,
ctrl: true,
cmd: false,
});
- expect(clearApiKey).toHaveBeenCalled();
- expect(mockBuffer.setText).toHaveBeenCalledWith('');
+ await waitFor(() => {
+ expect(clearApiKey).toHaveBeenCalled();
+ expect(mockBuffer.setText).toHaveBeenCalledWith('');
+ });
});
});
diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
index f76fb90edb..a9864e27af 100644
--- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx
+++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
@@ -86,10 +86,12 @@ export function ApiAuthDialog({
};
useKeypress(
- async (key) => {
+ (key) => {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
- await handleClear();
+ void handleClear();
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx
index 0799b38b70..0acb27e2af 100644
--- a/packages/cli/src/ui/auth/AuthDialog.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.tsx
@@ -169,18 +169,20 @@ export function AuthDialog({
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (authError) {
- return;
+ return true;
}
if (settings.merged.security.auth.selectedType === undefined) {
// Prevent exiting if no auth method is set
onAuthError(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
- return;
+ return true;
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
onSelect(undefined, SettingScope.User);
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
index 03a18bced7..86cd645fee 100644
--- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
+++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
@@ -24,6 +24,7 @@ export const LoginWithGoogleRestartDialog = ({
(key) => {
if (key.name === 'escape') {
onDismiss();
+ return true;
} else if (key.name === 'r' || key.name === 'R') {
setTimeout(async () => {
if (process.send) {
@@ -38,7 +39,9 @@ export const LoginWithGoogleRestartDialog = ({
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}, 100);
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
index 09571836c4..b697dc17c4 100644
--- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
+++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
@@ -17,7 +17,9 @@ export const AdminSettingsChangedDialog = () => {
(key) => {
if (keyMatchers[Command.RESTART_APP](key)) {
handleRestart();
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx
index bf9838b777..7ee45f96bd 100644
--- a/packages/cli/src/ui/components/AskUserDialog.test.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx
@@ -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
diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx
index 924d869604..4c74f2fd37 100644
--- a/packages/cli/src/ui/components/AskUserDialog.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.tsx
@@ -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 = ({
(key: Key) => {
if (keyMatchers[Command.RETURN](key)) {
onSubmit();
+ return true;
}
+ return false;
},
{ isActive: true },
);
@@ -235,11 +183,10 @@ const ReviewView: React.FC = ({
))}
-
-
- Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
-
-
+
);
};
@@ -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 = ({
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 = ({
}
}, [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 = ({
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 = ({
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 = ({
// 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> => {
const list: Array> = questionOptions.map(
@@ -841,11 +799,6 @@ const ChoiceQuestionView: React.FC = ({
);
};
-/**
- * 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 = ({
questions,
onSubmit,
@@ -853,30 +806,29 @@ export const AskUserDialog: React.FC = ({
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 = ({
};
}, [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 = ({
(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 = ({
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 = ({
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 = ({
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 = ({
/>
) : null;
- // Render Review tab when on it
if (isOnReviewTab) {
return (
-
+
+
+
);
}
- // Safeguard for invalid question index
if (!currentQuestion) return null;
const keyboardHints = (
-
-
- {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'}
-
-
+ 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' ? (
+ ) : (
+
);
- }
return (
-
+
+ {questionView}
+
);
};
diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx
new file mode 100644
index 0000000000..a7a0e31714
--- /dev/null
+++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx
@@ -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(
+ ,
+ { 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/);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
index 671d3067cf..ade91da3ec 100644
--- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx
index b945739304..9886e3b5e4 100644
--- a/packages/cli/src/ui/components/FolderTrustDialog.tsx
+++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx
@@ -59,7 +59,9 @@ export const FolderTrustDialog: React.FC = ({
(key) => {
if (key.name === 'escape') {
handleExit();
+ return true;
}
+ return false;
},
{ isActive: !isRestarting },
);
diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
index 5ef6e76f2a..32e451a542 100644
--- a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
+++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 4f1a383987..cd82d7f674 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -372,7 +372,6 @@ export const InputPrompt: React.FC = ({
// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
- return;
}
}
@@ -469,7 +468,7 @@ export const InputPrompt: React.FC = ({
// 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 = ({
}
// 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 = ({
) {
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 = ({
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 = ({
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
- return;
+ return true;
}
// Second ESC
@@ -585,26 +584,26 @@ export const InputPrompt: React.FC = ({
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 = ({
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 = ({
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 = ({
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
- return;
+ return true;
}
}
@@ -683,7 +682,7 @@ export const InputPrompt: React.FC = ({
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
) {
handleSubmit(buffer.text);
- return;
+ return true;
}
if (completion.showSuggestions) {
@@ -691,12 +690,12 @@ export const InputPrompt: React.FC = ({
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 = ({
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 = ({
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
- return;
+ return true;
}
}
}
@@ -756,7 +755,7 @@ export const InputPrompt: React.FC = ({
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
- return;
+ return true;
}
}
@@ -767,7 +766,7 @@ export const InputPrompt: React.FC = ({
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
- return;
+ return true;
}
if (!shellModeActive) {
@@ -775,22 +774,22 @@ export const InputPrompt: React.FC = ({
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 = ({
// 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 = ({
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 = ({
// 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 = ({
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 = ({
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 = ({
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 = ({
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
+ return handled;
},
[
focus,
diff --git a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
index 97c73a96ed..e50d7ef568 100644
--- a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
+++ b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
@@ -28,7 +28,9 @@ export const LogoutConfirmationDialog: React.FC<
(key) => {
if (key.name === 'escape') {
onSelect(LogoutChoice.EXIT);
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx b/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
index d1393e7bee..5d4690e51b 100644
--- a/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
+++ b/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
@@ -27,7 +27,9 @@ export function LoopDetectionConfirmation({
onComplete({
userSelection: 'keep',
});
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx
index f0a27b7cf7..ed299f4f13 100644
--- a/packages/cli/src/ui/components/ModelDialog.tsx
+++ b/packages/cli/src/ui/components/ModelDialog.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx b/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
index c624d5fbfd..22d139d8fe 100644
--- a/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
+++ b/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
@@ -72,7 +72,9 @@ export const MultiFolderTrustDialog: React.FC = ({
if (key.name === 'escape') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleCancel();
+ return true;
}
+ return false;
},
{ isActive: !submitted },
);
diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
index 1b29826ed2..76ffe58b6f 100644
--- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
+++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx
index 5b9f4d8253..5ff7e5e10c 100644
--- a/packages/cli/src/ui/components/RewindConfirmation.tsx
+++ b/packages/cli/src/ui/components/RewindConfirmation.tsx
@@ -62,7 +62,9 @@ export const RewindConfirmation: React.FC = ({
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onConfirm(RewindOutcome.Cancel);
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx
index 38c026f3d1..2ab417888a 100644
--- a/packages/cli/src/ui/components/RewindViewer.tsx
+++ b/packages/cli/src/ui/components/RewindViewer.tsx
@@ -90,7 +90,7 @@ export const RewindViewer: React.FC = ({
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 = ({
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
+ return true;
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
+ return true;
}
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx
index 9e5836057c..9d1ce57f52 100644
--- a/packages/cli/src/ui/components/SessionBrowser.tsx
+++ b/packages/cli/src/ui/components/SessionBrowser.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index 3b5324e8f5..00298d49d3 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx
index 9c71e93403..6e126ea4ef 100644
--- a/packages/cli/src/ui/components/ValidationDialog.tsx
+++ b/packages/cli/src/ui/components/ValidationDialog.tsx
@@ -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' },
);
diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
index 84f2c8676f..54554740aa 100644
--- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
@@ -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 │
+╰─────────────────────────────────────────────────────────────────╯"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
index da745e2843..93fa48bf93 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
@@ -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) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 3aef6bd529..e77178ff11 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -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 },
);
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index dbe6d7b075..baec1bb8ca 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -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): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -74,6 +76,7 @@ export function BaseSelectionList<
showNumbers,
wrapAround,
focusKey,
+ priority,
});
const [scrollOffset, setScrollOffset] = useState(0);
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
index b1e21752a5..b65febaa04 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
@@ -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'}
/>
)}
diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx
new file mode 100644
index 0000000000..af75074645
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx
@@ -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 = ({
+ primaryAction,
+ navigationActions,
+ cancelAction = 'Esc to cancel',
+}) => {
+ const parts = [primaryAction];
+ if (navigationActions) {
+ parts.push(navigationActions);
+ }
+ parts.push(cancelAction);
+
+ return (
+
+ {parts.join(' · ')}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
index e7e48e5172..f21d6ce4c9 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
@@ -44,6 +44,8 @@ export interface RadioButtonSelectProps {
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,
@@ -66,6 +68,7 @@ export function RadioButtonSelect({
showScrollArrows = false,
maxItemsToShow = 10,
showNumbers = true,
+ priority,
renderItem,
}: RadioButtonSelectProps): React.JSX.Element {
return (
@@ -78,6 +81,7 @@ export function RadioButtonSelect({
showNumbers={showNumbers}
showScrollArrows={showScrollArrows}
maxItemsToShow={maxItemsToShow}
+ priority={priority}
renderItem={
renderItem ||
((item, { titleColor }) => {
diff --git a/packages/cli/src/ui/components/shared/TabHeader.test.tsx b/packages/cli/src/ui/components/shared/TabHeader.test.tsx
index 4ef8d86264..600d75728e 100644
--- a/packages/cli/src/ui/components/shared/TabHeader.test.tsx
+++ b/packages/cli/src/ui/components/shared/TabHeader.test.tsx
@@ -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();
});
});
});
diff --git a/packages/cli/src/ui/components/shared/TabHeader.tsx b/packages/cli/src/ui/components/shared/TabHeader.tsx
index c7fcbd7d81..a511c3cc4b 100644
--- a/packages/cli/src/ui/components/shared/TabHeader.tsx
+++ b/packages/cli/src/ui/components/shared/TabHeader.tsx
@@ -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 (
-
- {showArrows && {'\u2190 '}}
+
+ {showArrows && {'← '}}
{tabs.map((tab, i) => (
- {i > 0 && {' \u2502 '}}
+ {i > 0 && {' │ '}}
{showStatusIcons && (
{getStatusIcon(tab, i)}
)}
@@ -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}
))}
- {showArrows && {' \u2192'}}
+ {showArrows && {' →'}}
);
}
diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx
index e6c867f96c..4afbe7a0e7 100644
--- a/packages/cli/src/ui/components/shared/TextInput.tsx
+++ b/packages/cli/src/ui/components/shared/TextInput.tsx
@@ -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;
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/TabHeader.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/TabHeader.test.tsx.snap
new file mode 100644
index 0000000000..a386b838ef
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/TabHeader.test.tsx.snap
@@ -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 →
+"
+`;
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 90e6b3d71a..5188612585 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -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,
],
);
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index d622216275..661d562f83 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -215,7 +215,9 @@ function bufferBackslashEnter(
bufferer.next(); // prime the generator so it starts listening.
- return (key: Key) => bufferer.next(key);
+ return (key: Key) => {
+ bufferer.next(key);
+ };
}
/**
@@ -267,7 +269,9 @@ function bufferPaste(keypressHandler: KeypressHandler): KeypressHandler {
})();
bufferer.next(); // prime the generator so it starts listening.
- return (key: Key) => bufferer.next(key);
+ return (key: Key) => {
+ bufferer.next(key);
+ };
}
/**
@@ -622,10 +626,10 @@ export interface Key {
sequence: string;
}
-export type KeypressHandler = (key: Key) => void;
+export type KeypressHandler = (key: Key) => boolean | void;
interface KeypressContextValue {
- subscribe: (handler: KeypressHandler) => void;
+ subscribe: (handler: KeypressHandler, priority?: boolean) => void;
unsubscribe: (handler: KeypressHandler) => void;
}
@@ -654,18 +658,44 @@ export function KeypressProvider({
}) {
const { stdin, setRawMode } = useStdin();
- const subscribers = useRef>(new Set()).current;
+ const prioritySubscribers = useRef>(new Set()).current;
+ const normalSubscribers = useRef>(new Set()).current;
+
const subscribe = useCallback(
- (handler: KeypressHandler) => subscribers.add(handler),
- [subscribers],
+ (handler: KeypressHandler, priority = false) => {
+ const set = priority ? prioritySubscribers : normalSubscribers;
+ set.add(handler);
+ },
+ [prioritySubscribers, normalSubscribers],
);
+
const unsubscribe = useCallback(
- (handler: KeypressHandler) => subscribers.delete(handler),
- [subscribers],
+ (handler: KeypressHandler) => {
+ prioritySubscribers.delete(handler);
+ normalSubscribers.delete(handler);
+ },
+ [prioritySubscribers, normalSubscribers],
);
+
const broadcast = useCallback(
- (key: Key) => subscribers.forEach((handler) => handler(key)),
- [subscribers],
+ (key: Key) => {
+ // Process priority subscribers first, in reverse order (stack behavior: last subscribed is first to handle)
+ const priorityHandlers = Array.from(prioritySubscribers).reverse();
+ for (const handler of priorityHandlers) {
+ if (handler(key) === true) {
+ return;
+ }
+ }
+
+ // Then process normal subscribers, also in reverse order
+ const normalHandlers = Array.from(normalSubscribers).reverse();
+ for (const handler of normalHandlers) {
+ if (handler(key) === true) {
+ return;
+ }
+ }
+ },
+ [prioritySubscribers, normalSubscribers],
);
useEffect(() => {
diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts
index 1ff3ae2778..7df1b195a6 100644
--- a/packages/cli/src/ui/hooks/useKeypress.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.ts
@@ -16,10 +16,11 @@ export type { Key };
* @param onKeypress - The callback function to execute on each keypress.
* @param options - Options to control the hook's behavior.
* @param options.isActive - Whether the hook should be actively listening for input.
+ * @param options.priority - Whether the hook should have priority over normal subscribers.
*/
export function useKeypress(
onKeypress: KeypressHandler,
- { isActive }: { isActive: boolean },
+ { isActive, priority }: { isActive: boolean; priority?: boolean },
) {
const { subscribe, unsubscribe } = useKeypressContext();
@@ -28,9 +29,9 @@ export function useKeypress(
return;
}
- subscribe(onKeypress);
+ subscribe(onKeypress, priority);
return () => {
unsubscribe(onKeypress);
};
- }, [isActive, onKeypress, subscribe, unsubscribe]);
+ }, [isActive, onKeypress, subscribe, unsubscribe, priority]);
}
diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts
index 8e9f1ce357..80ca40a0ed 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.ts
+++ b/packages/cli/src/ui/hooks/useSelectionList.ts
@@ -30,6 +30,7 @@ export interface UseSelectionListOptions {
showNumbers?: boolean;
wrapAround?: boolean;
focusKey?: string;
+ priority?: boolean;
}
export interface UseSelectionListResult {
@@ -288,6 +289,7 @@ export function useSelectionList({
showNumbers = false,
wrapAround = true,
focusKey,
+ priority,
}: UseSelectionListOptions): UseSelectionListResult {
const baseItems = toBaseItems(items);
@@ -397,17 +399,17 @@ export function useSelectionList({
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({ type: 'MOVE_UP' });
- return;
+ return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({ type: 'MOVE_DOWN' });
- return;
+ return true;
}
if (keyMatchers[Command.RETURN](key)) {
dispatch({ type: 'SELECT_CURRENT' });
- return;
+ return true;
}
// Handle numeric input for quick selection
@@ -426,7 +428,7 @@ export function useSelectionList({
numberInputTimer.current = setTimeout(() => {
numberInputRef.current = '';
}, NUMBER_INPUT_TIMEOUT_MS);
- return;
+ return true;
}
if (targetIndex >= 0 && targetIndex < itemsLength) {
@@ -455,12 +457,17 @@ export function useSelectionList({
// Number is out of bounds
numberInputRef.current = '';
}
+ return true;
}
+ return false;
},
[dispatch, itemsLength, showNumbers],
);
- useKeypress(handleKeypress, { isActive: !!(isFocused && itemsLength > 0) });
+ useKeypress(handleKeypress, {
+ isActive: !!(isFocused && itemsLength > 0),
+ priority,
+ });
const setActiveIndex = (index: number) => {
dispatch({
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
index 351a4c08ae..5eb1107a4d 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
@@ -4,29 +4,128 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useTabbedNavigation } from './useTabbedNavigation.js';
+import { useKeypress } from './useKeypress.js';
+import type { Key, KeypressHandler } from '../contexts/KeypressContext.js';
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
+const createKey = (partial: Partial): Key => ({
+ name: partial.name || '',
+ sequence: partial.sequence || '',
+ shift: partial.shift || false,
+ alt: partial.alt || false,
+ ctrl: partial.ctrl || false,
+ cmd: partial.cmd || false,
+ insertable: partial.insertable || false,
+ ...partial,
+});
+
vi.mock('../keyMatchers.js', () => ({
keyMatchers: {
'cursor.left': vi.fn((key) => key.name === 'left'),
'cursor.right': vi.fn((key) => key.name === 'right'),
+ 'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
+ 'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
},
Command: {
MOVE_LEFT: 'cursor.left',
MOVE_RIGHT: 'cursor.right',
+ DIALOG_NEXT: 'dialog.next',
+ DIALOG_PREV: 'dialog.previous',
},
}));
describe('useTabbedNavigation', () => {
+ let capturedHandler: KeypressHandler;
+
beforeEach(() => {
- vi.clearAllMocks();
+ vi.mocked(useKeypress).mockImplementation((handler) => {
+ capturedHandler = handler;
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('keyboard navigation', () => {
+ it('moves to next tab on Right arrow', () => {
+ const { result } = renderHook(() =>
+ useTabbedNavigation({ tabCount: 3, enableArrowNavigation: true }),
+ );
+
+ act(() => {
+ capturedHandler(createKey({ name: 'right' }));
+ });
+
+ expect(result.current.currentIndex).toBe(1);
+ });
+
+ it('moves to previous tab on Left arrow', () => {
+ const { result } = renderHook(() =>
+ useTabbedNavigation({
+ tabCount: 3,
+ initialIndex: 1,
+ enableArrowNavigation: true,
+ }),
+ );
+
+ act(() => {
+ capturedHandler(createKey({ name: 'left' }));
+ });
+
+ expect(result.current.currentIndex).toBe(0);
+ });
+
+ it('moves to next tab on Tab key', () => {
+ const { result } = renderHook(() =>
+ useTabbedNavigation({ tabCount: 3, enableTabKey: true }),
+ );
+
+ act(() => {
+ capturedHandler(createKey({ name: 'tab', shift: false }));
+ });
+
+ expect(result.current.currentIndex).toBe(1);
+ });
+
+ it('moves to previous tab on Shift+Tab key', () => {
+ const { result } = renderHook(() =>
+ useTabbedNavigation({
+ tabCount: 3,
+ initialIndex: 1,
+ enableTabKey: true,
+ }),
+ );
+
+ act(() => {
+ capturedHandler(createKey({ name: 'tab', shift: true }));
+ });
+
+ expect(result.current.currentIndex).toBe(0);
+ });
+
+ it('does not navigate when isNavigationBlocked returns true', () => {
+ const { result } = renderHook(() =>
+ useTabbedNavigation({
+ tabCount: 3,
+ enableArrowNavigation: true,
+ isNavigationBlocked: () => true,
+ }),
+ );
+
+ act(() => {
+ capturedHandler(createKey({ name: 'right' }));
+ });
+
+ expect(result.current.currentIndex).toBe(0);
+ });
});
describe('initialization', () => {
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
index cb128b5861..b4ed73264c 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
@@ -214,8 +214,15 @@ export function useTabbedNavigation({
}
}
- if (enableTabKey && key.name === 'tab' && !key.shift) {
- goToNextTab();
+ if (enableTabKey) {
+ if (keyMatchers[Command.DIALOG_NEXT](key)) {
+ goToNextTab();
+ return;
+ }
+ if (keyMatchers[Command.DIALOG_PREV](key)) {
+ goToPrevTab();
+ return;
+ }
}
},
[
diff --git a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx
index fa602398cb..52175c0677 100644
--- a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx
+++ b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx
@@ -31,7 +31,9 @@ export const CloudFreePrivacyNotice = ({
key.name === 'escape'
) {
onExit();
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx
index ce640308ec..515f76118a 100644
--- a/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx
+++ b/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx
@@ -19,7 +19,9 @@ export const CloudPaidPrivacyNotice = ({
(key) => {
if (key.name === 'escape') {
onExit();
+ return true;
}
+ return false;
},
{ isActive: true },
);
diff --git a/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx b/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx
index 1f4015b5c2..42a549116d 100644
--- a/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx
+++ b/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx
@@ -17,7 +17,9 @@ export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => {
(key) => {
if (key.name === 'escape') {
onExit();
+ return true;
}
+ return false;
},
{ isActive: true },
);