diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 67f2d5dd84..dfa2d4af86 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -119,7 +119,7 @@ import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; -import { keyMatchers, Command } from './keyMatchers.js'; +import { Command } from './keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -164,7 +164,7 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; -import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; +import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; @@ -205,6 +205,7 @@ import { useVisibilityToggle, APPROVAL_MODE_REVEAL_DURATION_MS, } from './hooks/useVisibilityToggle.js'; +import { useKeyMatchers } from './hooks/useKeyMatchers.js'; /** * The fraction of the terminal width to allocate to the shell. @@ -219,6 +220,8 @@ const SHELL_WIDTH_FRACTION = 0.89; const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { + const isHelpDismissKey = useIsHelpDismissKey(); + const keyMatchers = useKeyMatchers(); const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); const { reset } = useOverflowActions()!; @@ -1654,7 +1657,7 @@ Logging in with Google... Restarting Gemini CLI to continue. debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } - if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1848,6 +1851,8 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.general.devtools, showErrorDetails, triggerExpandHint, + keyMatchers, + isHelpDismissKey, ], ); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index 2caad6fd27..a62d34c866 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -13,7 +13,8 @@ import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { clearApiKey, debugLogger } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface ApiAuthDialogProps { onSubmit: (apiKey: string) => void; @@ -28,6 +29,7 @@ export function ApiAuthDialog({ error, defaultValue = '', }: ApiAuthDialogProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); const { terminalWidth } = useUIState(); const viewportWidth = terminalWidth - 8; diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx index b697dc17c4..2507d31f2b 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx @@ -8,9 +8,11 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export const AdminSettingsChangedDialog = () => { + const keyMatchers = useKeyMatchers(); const { handleRestart } = useUIActions(); useKeypress( diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 284e4e1df8..e55617a724 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -20,7 +20,7 @@ import { BaseSelectionList } from './shared/BaseSelectionList.js'; import type { SelectionListItem } from '../hooks/useSelectionList.js'; import { TabHeader, type Tab } from './shared/TabHeader.js'; import { useKeypress, type Key } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; import { formatCommand } from '../utils/keybindingUtils.js'; @@ -36,6 +36,7 @@ import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** Padding for dialog content to prevent text from touching edges. */ const DIALOG_PADDING = 4; @@ -208,6 +209,7 @@ const ReviewView: React.FC = ({ progressHeader, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -288,6 +290,7 @@ const TextQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor @@ -325,7 +328,7 @@ const TextQuestionView: React.FC = ({ } return false; }, - [buffer, textValue], + [buffer, textValue, keyMatchers], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -487,6 +490,7 @@ const ChoiceQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); @@ -680,6 +684,7 @@ const ChoiceQuestionView: React.FC = ({ customBuffer, onEditingCustomOption, customOptionText, + keyMatchers, ], ); @@ -950,6 +955,7 @@ export const AskUserDialog: React.FC = ({ availableHeight: availableHeightProp, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const uiState = useContext(UIStateContext); const availableHeight = availableHeightProp ?? @@ -999,7 +1005,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [onCancel, submitted, isEditingCustomOption], + [onCancel, submitted, isEditingCustomOption, keyMatchers], ); useKeypress(handleCancel, { @@ -1032,7 +1038,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [questions.length, submitted, goToNextTab, goToPrevTab], + [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], ); useKeypress(handleNavigation, { diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 16093ef0d7..946e062c19 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -16,7 +16,7 @@ import { } from '@google/gemini-cli-core'; import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { formatCommand } from '../utils/keybindingUtils.js'; import { @@ -30,6 +30,7 @@ import { RadioButtonSelect, type RadioSelectItem, } from './shared/RadioButtonSelect.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface BackgroundShellDisplayProps { shells: Map; @@ -60,6 +61,7 @@ export const BackgroundShellDisplay = ({ isFocused, isListOpenProp, }: BackgroundShellDisplayProps) => { + const keyMatchers = useKeyMatchers(); const { dismissBackgroundShell, setActiveBackgroundShellPid, diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 2bf1f723a6..35d0d2e719 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -10,7 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { ApprovalMode, validatePlanContent, @@ -18,6 +18,7 @@ import { type FileSystemService, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; vi.mock('../utils/editorUtils.js', () => ({ openFileInEditor: vi.fn(), @@ -402,6 +403,7 @@ Implement a comprehensive authentication system with multiple providers. }: { children: React.ReactNode; }) => { + const keyMatchers = useKeyMatchers(); useKeypress( (key) => { if (keyMatchers[Command.QUIT](key)) { diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 39e1b8a155..d5f1983c14 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -22,8 +22,9 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { AskUserDialog } from './AskUserDialog.js'; import { openFileInEditor } from '../utils/editorUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { formatCommand } from '../utils/keybindingUtils.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export interface ExitPlanModeDialogProps { planPath: string; @@ -147,6 +148,7 @@ export const ExitPlanModeDialog: React.FC = ({ width, availableHeight, }) => { + const keyMatchers = useKeyMatchers(); const config = useConfig(); const { stdin, setRawMode } = useStdin(); const planState = usePlanContent(planPath, config); diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx index c31dc73e45..03560d4e21 100644 --- a/packages/cli/src/ui/components/FooterConfigDialog.tsx +++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx @@ -11,13 +11,14 @@ import { theme } from '../semantic-colors.js'; import { useSettingsStore } from '../contexts/SettingsContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useKeypress, type Key } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { FooterRow, type FooterRowItem } from './Footer.js'; import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js'; import { SettingScope } from '../../config/settings.js'; import { BaseSelectionList } from './shared/BaseSelectionList.js'; import type { SelectionListItem } from '../hooks/useSelectionList.js'; import { DialogFooter } from './shared/DialogFooter.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface FooterConfigDialogProps { onClose?: () => void; @@ -82,6 +83,7 @@ function footerConfigReducer( export const FooterConfigDialog: React.FC = ({ onClose, }) => { + const keyMatchers = useKeyMatchers(); const { settings, setSetting } = useSettingsStore(); const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState(); const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => diff --git a/packages/cli/src/ui/components/HooksDialog.tsx b/packages/cli/src/ui/components/HooksDialog.tsx index d820aba6e7..4fd7b9ff9d 100644 --- a/packages/cli/src/ui/components/HooksDialog.tsx +++ b/packages/cli/src/ui/components/HooksDialog.tsx @@ -9,7 +9,8 @@ import { useState, useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** * Hook entry type matching HookRegistryEntry from core @@ -49,6 +50,7 @@ export const HooksDialog: React.FC = ({ onClose, maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS, }) => { + const keyMatchers = useKeyMatchers(); const [scrollOffset, setScrollOffset] = useState(0); // Flatten hooks with their event names for easier scrolling diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index b8148b0bef..85e6b8d6aa 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -44,7 +44,7 @@ import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js import type { UIState } from '../contexts/UIStateContext.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; import { cpLen } from '../utils/textUtils.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { defaultKeyMatchers, Command } from '../keyMatchers.js'; import type { Key } from '../hooks/useKeypress.js'; import { appEvents, @@ -197,7 +197,7 @@ describe('InputPrompt', () => { visualCursor: [0, 0], visualScrollRow: 0, handleInput: vi.fn((key: Key) => { - if (keyMatchers[Command.CLEAR_INPUT](key)) { + if (defaultKeyMatchers[Command.CLEAR_INPUT](key)) { if (mockBuffer.text.length > 0) { mockBuffer.setText(''); return true; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 373571f07d..1d82c87f70 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -36,7 +36,7 @@ import { } from '../hooks/useCommandCompletion.js'; import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { formatCommand } from '../utils/keybindingUtils.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@google/gemini-cli-core'; @@ -72,8 +72,9 @@ import { useMouseClick } from '../hooks/useMouseClick.js'; import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; -import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js'; +import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js'; import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** * Returns if the terminal can be trusted to handle paste events atomically @@ -207,6 +208,8 @@ export const InputPrompt: React.FC = ({ suggestionsPosition = 'below', setBannerVisible, }) => { + const isHelpDismissKey = useIsHelpDismissKey(); + const keyMatchers = useKeyMatchers(); const { stdout } = useStdout(); const { merged: settings } = useSettings(); const kittyProtocol = useKittyKeyboardProtocol(); @@ -737,7 +740,7 @@ export const InputPrompt: React.FC = ({ return true; } - if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1265,6 +1268,8 @@ export const InputPrompt: React.FC = ({ shouldShowSuggestions, isShellSuggestionsVisible, forceShowShellSuggestions, + keyMatchers, + isHelpDismissKey, ], ); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx index e6ed75c4db..ad48571fff 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -16,7 +16,8 @@ import { theme } from '../semantic-colors.js'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export enum PolicyUpdateChoice { ACCEPT = 'accept', @@ -34,6 +35,7 @@ export const PolicyUpdateDialog: React.FC = ({ request, onClose, }) => { + const keyMatchers = useKeyMatchers(); const isProcessing = useRef(false); const handleSelect = useCallback( diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx index bbfbf9dbee..fa58995731 100644 --- a/packages/cli/src/ui/components/RewindConfirmation.tsx +++ b/packages/cli/src/ui/components/RewindConfirmation.tsx @@ -13,7 +13,8 @@ import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import type { FileChangeStats } from '../utils/rewindFileOps.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { formatTimeAgo } from '../utils/formatters.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export enum RewindOutcome { RewindAndRevert = 'rewind_and_revert', @@ -58,6 +59,7 @@ export const RewindConfirmation: React.FC = ({ terminalWidth, timestamp, }) => { + const keyMatchers = useKeyMatchers(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); useKeypress( (key) => { diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index 26f7282f61..0a9f858d3d 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -19,9 +19,10 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { useRewind } from '../hooks/useRewind.js'; import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js'; import { stripReferenceContent } from '../utils/formatters.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { CliSpinner } from './CliSpinner.js'; import { ExpandableText } from './shared/ExpandableText.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface RewindViewerProps { conversation: ConversationRecord; @@ -48,6 +49,7 @@ export const RewindViewer: React.FC = ({ onExit, onRewind, }) => { + const keyMatchers = useKeyMatchers(); const [isRewinding, setIsRewinding] = useState(false); const { terminalWidth, terminalHeight } = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 26e32d946f..dae0f65312 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -10,7 +10,8 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js'; import { ACTIVE_SHELL_MAX_LINES } from '../constants.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export interface ShellInputPromptProps { activeShellPtyId: number | null; @@ -23,6 +24,7 @@ export const ShellInputPrompt: React.FC = ({ focus = true, scrollPageSize = ACTIVE_SHELL_MAX_LINES, }) => { + const keyMatchers = useKeyMatchers(); const handleShellInputSubmit = useCallback( (input: string) => { if (activeShellPtyId) { @@ -73,7 +75,13 @@ export const ShellInputPrompt: React.FC = ({ return false; }, - [focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize], + [ + focus, + handleShellInputSubmit, + activeShellPtyId, + scrollPageSize, + keyMatchers, + ], ); useKeypress(handleInput, { isActive: focus }); diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx index 6e126ea4ef..f94de6b86d 100644 --- a/packages/cli/src/ui/components/ValidationDialog.tsx +++ b/packages/cli/src/ui/components/ValidationDialog.tsx @@ -16,7 +16,8 @@ import { type ValidationIntent, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface ValidationDialogProps { validationLink?: string; @@ -32,6 +33,7 @@ export function ValidationDialog({ learnMoreUrl, onChoice, }: ValidationDialogProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); const [state, setState] = useState('choosing'); const [errorMessage, setErrorMessage] = useState(''); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index b97a29565b..1ace75633c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -29,7 +29,7 @@ import { import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; import { formatCommand } from '../../utils/keybindingUtils.js'; import { AskUserDialog } from '../AskUserDialog.js'; import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js'; @@ -40,6 +40,7 @@ import { toUnicodeUrl, type DeceptiveUrlDetails, } from '../../utils/urlSecurityUtils.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; export interface ToolConfirmationMessageProps { callId: string; @@ -67,6 +68,7 @@ export const ToolConfirmationMessage: React.FC< availableTerminalHeight, terminalWidth, }) => { + const keyMatchers = useKeyMatchers(); const { confirm, isDiffingEnabled } = useToolActions(); const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{ callId: string; diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index bccde9766d..45dda8b38c 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -19,10 +19,11 @@ import { TextInput } from './TextInput.js'; import type { TextBuffer } from './text-buffer.js'; import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js'; import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js'; import { formatCommand } from '../../utils/keybindingUtils.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; /** * Represents a single item in the settings dialog. @@ -136,6 +137,7 @@ export function BaseSettingsDialog({ availableHeight, footer, }: BaseSettingsDialogProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); // Calculate effective max items and scope visibility based on terminal height const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => { const initialShowScope = showScopeSelector; diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index a7227c7087..a1f9be0b7c 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -19,8 +19,9 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; import { useOverflowActions } from '../../contexts/OverflowContext.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; interface ScrollableProps { children?: React.ReactNode; @@ -45,6 +46,7 @@ export const Scrollable: React.FC = ({ flexGrow, reportOverflow = false, }) => { + const keyMatchers = useKeyMatchers(); const [scrollTop, setScrollTop] = useState(0); const viewportRef = useRef(null); const contentRef = useRef(null); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index b7085329a3..33a3f72310 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -22,7 +22,8 @@ import { useScrollable } from '../../contexts/ScrollProvider.js'; import { Box, type DOMElement } from 'ink'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; const ANIMATION_FRAME_DURATION_MS = 33; @@ -46,6 +47,7 @@ function ScrollableList( props: ScrollableListProps, ref: React.Ref>, ) { + const keyMatchers = useKeyMatchers(); const { hasFocus, width } = props; const virtualizedListRef = useRef>(null); const containerRef = useRef(null); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index 1611bc2842..046040af90 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -11,7 +11,8 @@ import { useSelectionList } from '../../hooks/useSelectionList.js'; import { TextInput } from './TextInput.js'; import type { TextBuffer } from './text-buffer.js'; import { useKeypress } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; /** * Generic interface for items in a searchable list. @@ -85,6 +86,7 @@ export function SearchableList({ onSearch, resetSelectionOnItemsChange = false, }: SearchableListProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ items, onSearch, diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 8a4745eea7..cc3fcaeb8d 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -14,7 +14,8 @@ import { theme } from '../../semantic-colors.js'; import type { TextBuffer } from './text-buffer.js'; import { expandPastePlaceholders } from './text-buffer.js'; import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; export interface TextInputProps { buffer: TextBuffer; @@ -31,6 +32,7 @@ export function TextInput({ onCancel, focus = true, }: TextInputProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); const { text, handleInput, @@ -55,7 +57,7 @@ export function TextInput({ const handled = handleInput(key); return handled; }, - [handleInput, onCancel, onSubmit, text, buffer.pastedContent], + [handleInput, onCancel, onSubmit, text, buffer.pastedContent, keyMatchers], ); useKeypress(handleKeyPress, { isActive: focus, priority: true }); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 34d757a61b..808fc8a554 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -25,11 +25,12 @@ import { } from '../../utils/textUtils.js'; import { parsePastedPaths } from '../../utils/clipboardUtils.js'; import type { Key } from '../../contexts/KeypressContext.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; import { openFileInEditor } from '../../utils/editorUtils.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; export const LARGE_PASTE_LINE_THRESHOLD = 5; export const LARGE_PASTE_CHAR_THRESHOLD = 500; @@ -2708,6 +2709,7 @@ export function useTextBuffer({ singleLine = false, getPreferredEditor, }: UseTextBufferProps): TextBuffer { + const keyMatchers = useKeyMatchers(); const initialState = useMemo((): TextBufferState => { const lines = initialText.split('\n'); const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition( @@ -3270,6 +3272,7 @@ export function useTextBuffer({ text, visualCursor, visualLines, + keyMatchers, ], ); diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx index 878cacfed0..4de6568189 100644 --- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx +++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx @@ -10,7 +10,8 @@ import Spinner from 'ink-spinner'; import type { Config } from '@google/gemini-cli-core'; import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core'; import { useKeypress } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; interface Issue { number: number; @@ -106,6 +107,7 @@ export const TriageDuplicates = ({ onExit: () => void; initialLimit?: number; }) => { + const keyMatchers = useKeyMatchers(); const [state, setState] = useState({ status: 'loading', issues: [], diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx index 595384a124..e6779d6c02 100644 --- a/packages/cli/src/ui/components/triage/TriageIssues.tsx +++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx @@ -10,9 +10,10 @@ import Spinner from 'ink-spinner'; import type { Config } from '@google/gemini-cli-core'; import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core'; import { useKeypress } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; +import { Command } from '../../keyMatchers.js'; import { TextInput } from '../shared/TextInput.js'; import { useTextBuffer } from '../shared/text-buffer.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; interface Issue { number: number; @@ -69,6 +70,7 @@ export const TriageIssues = ({ initialLimit?: number; until?: string; }) => { + const keyMatchers = useKeyMatchers(); const [state, setState] = useState({ status: 'loading', issues: [], diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts index 1b5076027f..84e465106f 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts @@ -11,7 +11,8 @@ import { getAdminErrorMessage, } from '@google/gemini-cli-core'; import { useKeypress } from './useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from './useKeyMatchers.js'; import type { HistoryItemWithoutId } from '../types.js'; import { MessageType } from '../types.js'; @@ -30,6 +31,7 @@ export function useApprovalModeIndicator({ isActive = true, allowPlanMode = false, }: UseApprovalModeIndicatorArgs): ApprovalMode { + const keyMatchers = useKeyMatchers(); const currentConfigValue = config.getApprovalMode(); const [showApprovalMode, setApprovalMode] = useState(currentConfigValue); diff --git a/packages/cli/src/ui/hooks/useKeyMatchers.ts b/packages/cli/src/ui/hooks/useKeyMatchers.ts new file mode 100644 index 0000000000..a42a066ee0 --- /dev/null +++ b/packages/cli/src/ui/hooks/useKeyMatchers.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import type { KeyMatchers } from '../keyMatchers.js'; +import { defaultKeyMatchers } from '../keyMatchers.js'; + +/** + * Hook to retrieve the currently active key matchers. + * This prepares the codebase for dynamic or custom key bindings in the future. + */ +export function useKeyMatchers(): KeyMatchers { + return useMemo(() => defaultKeyMatchers, []); +} diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts index f74c1b1dc2..9f73c54da4 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.ts @@ -6,8 +6,9 @@ import { useReducer, useRef, useEffect, useCallback } from 'react'; import { useKeypress, type Key } from './useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import { debugLogger } from '@google/gemini-cli-core'; +import { useKeyMatchers } from './useKeyMatchers.js'; export interface SelectionListItem { key: string; @@ -290,6 +291,7 @@ export function useSelectionList({ focusKey, priority, }: UseSelectionListOptions): UseSelectionListResult { + const keyMatchers = useKeyMatchers(); const baseItems = toBaseItems(items); const [state, dispatch] = useReducer(selectionListReducer, { @@ -460,7 +462,7 @@ export function useSelectionList({ } return false; }, - [dispatch, itemsLength, showNumbers], + [dispatch, itemsLength, showNumbers, keyMatchers], ); useKeypress(handleKeypress, { diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts index 5eb1107a4d..e41a89d66d 100644 --- a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts +++ b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts @@ -9,12 +9,18 @@ import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { useTabbedNavigation } from './useTabbedNavigation.js'; import { useKeypress } from './useKeypress.js'; +import { useKeyMatchers } from './useKeyMatchers.js'; +import type { KeyMatchers } from '../keyMatchers.js'; import type { Key, KeypressHandler } from '../contexts/KeypressContext.js'; vi.mock('./useKeypress.js', () => ({ useKeypress: vi.fn(), })); +vi.mock('./useKeyMatchers.js', () => ({ + useKeyMatchers: vi.fn(), +})); + const createKey = (partial: Partial): Key => ({ name: partial.name || '', sequence: partial.sequence || '', @@ -26,13 +32,14 @@ const createKey = (partial: Partial): Key => ({ ...partial, }); +const mockKeyMatchers = { + '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), +} as unknown as KeyMatchers; + 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', @@ -45,6 +52,7 @@ describe('useTabbedNavigation', () => { let capturedHandler: KeypressHandler; beforeEach(() => { + vi.mocked(useKeyMatchers).mockReturnValue(mockKeyMatchers); vi.mocked(useKeypress).mockImplementation((handler) => { capturedHandler = handler; }); diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.ts index b4ed73264c..d7e406ce6b 100644 --- a/packages/cli/src/ui/hooks/useTabbedNavigation.ts +++ b/packages/cli/src/ui/hooks/useTabbedNavigation.ts @@ -6,7 +6,8 @@ import { useReducer, useCallback, useEffect, useRef } from 'react'; import { useKeypress, type Key } from './useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from './useKeyMatchers.js'; /** * Options for the useTabbedNavigation hook. @@ -147,6 +148,7 @@ export function useTabbedNavigation({ isActive = true, onTabChange, }: UseTabbedNavigationOptions): UseTabbedNavigationResult { + const keyMatchers = useKeyMatchers(); const [state, dispatch] = useReducer(tabbedNavigationReducer, { currentIndex: Math.max(0, Math.min(initialIndex, tabCount - 1)), tabCount, @@ -231,6 +233,7 @@ export function useTabbedNavigation({ goToNextTab, goToPrevTab, isNavigationBlocked, + keyMatchers, ], ); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 9de771564c..1fcc0c61ca 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -9,7 +9,8 @@ import type { Key } from './useKeypress.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { debugLogger } from '@google/gemini-cli-core'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; +import { useKeyMatchers } from './useKeyMatchers.js'; export type VimMode = 'NORMAL' | 'INSERT'; @@ -152,6 +153,7 @@ const vimReducer = (state: VimState, action: VimAction): VimState => { * @returns Object with vim state and input handler */ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { + const keyMatchers = useKeyMatchers(); const { vimEnabled, vimMode, setVimMode } = useVimMode(); const [state, dispatch] = useReducer(vimReducer, initialVimState); @@ -439,7 +441,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return buffer.handleInput(normalizedKey); }, - [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape], + [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape, keyMatchers], ); /** @@ -1202,6 +1204,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { executeCommand, updateMode, checkDoubleEscape, + keyMatchers, ], ); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 888393be83..e90f6334be 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { keyMatchers, Command, createKeyMatchers } from './keyMatchers.js'; +import { + defaultKeyMatchers, + Command, + createKeyMatchers, +} from './keyMatchers.js'; import type { KeyBindingConfig } from '../config/keyBindings.js'; import { defaultKeyBindings } from '../config/keyBindings.js'; import type { Key } from './hooks/useKeypress.js'; @@ -422,14 +426,14 @@ describe('keyMatchers', () => { it(`should match ${command} correctly`, () => { positive.forEach((key) => { expect( - keyMatchers[command](key), + defaultKeyMatchers[command](key), `Expected ${command} to match ${JSON.stringify(key)}`, ).toBe(true); }); negative.forEach((key) => { expect( - keyMatchers[command](key), + defaultKeyMatchers[command](key), `Expected ${command} to NOT match ${JSON.stringify(key)}`, ).toBe(false); }); diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts index f833e5ee09..259f1edd9e 100644 --- a/packages/cli/src/ui/keyMatchers.ts +++ b/packages/cli/src/ui/keyMatchers.ts @@ -68,7 +68,8 @@ export function createKeyMatchers( /** * Default key binding matchers using the default configuration */ -export const keyMatchers: KeyMatchers = createKeyMatchers(defaultKeyBindings); +export const defaultKeyMatchers: KeyMatchers = + createKeyMatchers(defaultKeyBindings); // Re-export Command for convenience export { Command }; diff --git a/packages/cli/src/ui/utils/shortcutsHelp.ts b/packages/cli/src/ui/utils/shortcutsHelp.ts index 65ab8f2a13..a5f6d22e19 100644 --- a/packages/cli/src/ui/utils/shortcutsHelp.ts +++ b/packages/cli/src/ui/utils/shortcutsHelp.ts @@ -4,9 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../keyMatchers.js'; import type { Key } from '../hooks/useKeypress.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; -export function shouldDismissShortcutsHelpOnHotkey(key: Key): boolean { - return Object.values(Command).some((command) => keyMatchers[command](key)); +export function useIsHelpDismissKey(): (key: Key) => boolean { + const keyMatchers = useKeyMatchers(); + return (key: Key) => + Object.values(Command).some((command) => keyMatchers[command](key)); }