From b51323b40c30929adfb744413364bcfc3280300e Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 27 Jan 2026 14:26:00 -0800 Subject: [PATCH] refactor(cli): keyboard handling and AskUserDialog (#17414) --- docs/cli/keyboard-shortcuts.md | 14 +- packages/cli/src/config/keyBindings.ts | 8 + packages/cli/src/ui/AppContainer.tsx | 21 +- packages/cli/src/ui/IdeIntegrationNudge.tsx | 2 + .../cli/src/ui/auth/ApiAuthDialog.test.tsx | 12 +- packages/cli/src/ui/auth/ApiAuthDialog.tsx | 6 +- packages/cli/src/ui/auth/AuthDialog.tsx | 6 +- .../ui/auth/LoginWithGoogleRestartDialog.tsx | 3 + .../components/AdminSettingsChangedDialog.tsx | 2 + .../src/ui/components/AskUserDialog.test.tsx | 26 +- .../cli/src/ui/components/AskUserDialog.tsx | 393 ++++++++---------- .../ui/components/BubblingRegression.test.tsx | 74 ++++ .../ui/components/EditorSettingsDialog.tsx | 3 + .../src/ui/components/FolderTrustDialog.tsx | 2 + .../ui/components/IdeTrustChangeDialog.tsx | 2 + .../cli/src/ui/components/InputPrompt.tsx | 102 ++--- .../components/LogoutConfirmationDialog.tsx | 2 + .../components/LoopDetectionConfirmation.tsx | 2 + .../cli/src/ui/components/ModelDialog.tsx | 3 + .../ui/components/MultiFolderTrustDialog.tsx | 2 + .../PermissionsModifyTrustDialog.tsx | 3 + .../src/ui/components/RewindConfirmation.tsx | 2 + .../cli/src/ui/components/RewindViewer.tsx | 5 +- .../cli/src/ui/components/SessionBrowser.tsx | 18 + .../cli/src/ui/components/ThemeDialog.tsx | 3 + .../src/ui/components/ValidationDialog.tsx | 3 + .../__snapshots__/AskUserDialog.test.tsx.snap | 48 +-- .../SettingsDialog.test.tsx.snap | 368 ++++++++++++++++ .../messages/ToolConfirmationMessage.tsx | 4 +- .../components/shared/BaseSelectionList.tsx | 3 + .../components/shared/BaseSettingsDialog.tsx | 13 +- .../src/ui/components/shared/DialogFooter.tsx | 40 ++ .../components/shared/RadioButtonSelect.tsx | 4 + .../ui/components/shared/TabHeader.test.tsx | 11 + .../src/ui/components/shared/TabHeader.tsx | 13 +- .../src/ui/components/shared/TextInput.tsx | 17 +- .../__snapshots__/TabHeader.test.tsx.snap | 56 +++ .../src/ui/components/shared/text-buffer.ts | 113 ++++- .../cli/src/ui/contexts/KeypressContext.tsx | 52 ++- packages/cli/src/ui/hooks/useKeypress.ts | 7 +- packages/cli/src/ui/hooks/useSelectionList.ts | 17 +- .../src/ui/hooks/useTabbedNavigation.test.ts | 103 ++++- .../cli/src/ui/hooks/useTabbedNavigation.ts | 11 +- .../src/ui/privacy/CloudFreePrivacyNotice.tsx | 2 + .../src/ui/privacy/CloudPaidPrivacyNotice.tsx | 2 + .../src/ui/privacy/GeminiPrivacyNotice.tsx | 2 + 46 files changed, 1220 insertions(+), 385 deletions(-) create mode 100644 packages/cli/src/ui/components/BubblingRegression.test.tsx create mode 100644 packages/cli/src/ui/components/shared/DialogFooter.tsx create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/TabHeader.test.tsx.snap 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 }, );