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

View File

@@ -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,
],
);

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<ReviewViewProps> = ({
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<TextQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
const prefix = '> ';
const horizontalPadding = 1; // 1 for cursor
@@ -325,7 +328,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
}
return false;
},
[buffer, textValue],
[buffer, textValue, keyMatchers],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -487,6 +490,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
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<ChoiceQuestionViewProps> = ({
customBuffer,
onEditingCustomOption,
customOptionText,
keyMatchers,
],
);
@@ -950,6 +955,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
availableHeight: availableHeightProp,
extraParts,
}) => {
const keyMatchers = useKeyMatchers();
const uiState = useContext(UIStateContext);
const availableHeight =
availableHeightProp ??
@@ -999,7 +1005,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
}
return false;
},
[onCancel, submitted, isEditingCustomOption],
[onCancel, submitted, isEditingCustomOption, keyMatchers],
);
useKeypress(handleCancel, {
@@ -1032,7 +1038,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
}
return false;
},
[questions.length, submitted, goToNextTab, goToPrevTab],
[questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers],
);
useKeypress(handleNavigation, {

View File

@@ -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<number, BackgroundShell>;
@@ -60,6 +61,7 @@ export const BackgroundShellDisplay = ({
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,

View File

@@ -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)) {

View File

@@ -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<ExitPlanModeDialogProps> = ({
width,
availableHeight,
}) => {
const keyMatchers = useKeyMatchers();
const config = useConfig();
const { stdin, setRawMode } = useStdin();
const planState = usePlanContent(planPath, config);

View File

@@ -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<FooterConfigDialogProps> = ({
onClose,
}) => {
const keyMatchers = useKeyMatchers();
const { settings, setSetting } = useSettingsStore();
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>

View File

@@ -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<HooksDialogProps> = ({
onClose,
maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollOffset, setScrollOffset] = useState(0);
// Flatten hooks with their event names for easier scrolling

View File

@@ -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;

View File

@@ -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<InputPromptProps> = ({
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<InputPromptProps> = ({
return true;
}
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1265,6 +1268,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shouldShowSuggestions,
isShellSuggestionsVisible,
forceShowShellSuggestions,
keyMatchers,
isHelpDismissKey,
],
);

View File

@@ -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<PolicyUpdateDialogProps> = ({
request,
onClose,
}) => {
const keyMatchers = useKeyMatchers();
const isProcessing = useRef(false);
const handleSelect = useCallback(

View File

@@ -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<RewindConfirmationProps> = ({
terminalWidth,
timestamp,
}) => {
const keyMatchers = useKeyMatchers();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
useKeypress(
(key) => {

View File

@@ -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<RewindViewerProps> = ({
onExit,
onRewind,
}) => {
const keyMatchers = useKeyMatchers();
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();

View File

@@ -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<ShellInputPromptProps> = ({
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<ShellInputPromptProps> = ({
return false;
},
[focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
[
focus,
handleShellInputSubmit,
activeShellPtyId,
scrollPageSize,
keyMatchers,
],
);
useKeypress(handleInput, { isActive: focus });

View File

@@ -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<DialogState>('choosing');
const [errorMessage, setErrorMessage] = useState<string>('');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<ScrollableProps> = ({
flexGrow,
reportOverflow = false,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);

View File

@@ -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<T>(
props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);

View File

@@ -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<T extends GenericListItem>({
onSearch,
resetSelectionOnItemsChange = false,
}: SearchableListProps<T>): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
items,
onSearch,

View File

@@ -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 });

View File

@@ -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,
],
);

View File

@@ -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<TriageState>({
status: 'loading',
issues: [],

View File

@@ -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<TriageState>({
status: 'loading',
issues: [],

View File

@@ -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);

View File

@@ -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, []);
}

View File

@@ -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<T> {
key: string;
@@ -290,6 +291,7 @@ export function useSelectionList<T>({
focusKey,
priority,
}: UseSelectionListOptions<T>): UseSelectionListResult {
const keyMatchers = useKeyMatchers();
const baseItems = toBaseItems(items);
const [state, dispatch] = useReducer(selectionListReducer, {
@@ -460,7 +462,7 @@ export function useSelectionList<T>({
}
return false;
},
[dispatch, itemsLength, showNumbers],
[dispatch, itemsLength, showNumbers, keyMatchers],
);
useKeypress(handleKeypress, {

View File

@@ -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>): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
@@ -26,13 +32,14 @@ const createKey = (partial: Partial<Key>): 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;
});

View File

@@ -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,
],
);

View File

@@ -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,
],
);

View File

@@ -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);
});

View File

@@ -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 };

View File

@@ -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));
}