refactor: migrate to useKeyMatchers hook (#21753)

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