From b8084ba8158b89facd49fd78a51abb80b1db54da Mon Sep 17 00:00:00 2001 From: Lee Won Jun Date: Sat, 9 Aug 2025 16:03:17 +0900 Subject: [PATCH] Centralize Key Binding Logic and Refactor (Reopen) (#5356) Co-authored-by: Lee-WonJun <10369528+Lee-WonJun@users.noreply.github.com> --- packages/cli/src/config/keyBindings.test.ts | 62 ++++ packages/cli/src/config/keyBindings.ts | 180 ++++++++++ packages/cli/src/ui/App.tsx | 107 +++--- .../cli/src/ui/components/InputPrompt.tsx | 68 ++-- packages/cli/src/ui/keyMatchers.test.ts | 338 ++++++++++++++++++ packages/cli/src/ui/keyMatchers.ts | 105 ++++++ 6 files changed, 788 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/config/keyBindings.test.ts create mode 100644 packages/cli/src/config/keyBindings.ts create mode 100644 packages/cli/src/ui/keyMatchers.test.ts create mode 100644 packages/cli/src/ui/keyMatchers.ts diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts new file mode 100644 index 00000000..2e89e421 --- /dev/null +++ b/packages/cli/src/config/keyBindings.test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + Command, + KeyBindingConfig, + defaultKeyBindings, +} from './keyBindings.js'; + +describe('keyBindings config', () => { + describe('defaultKeyBindings', () => { + it('should have bindings for all commands', () => { + const commands = Object.values(Command); + + for (const command of commands) { + expect(defaultKeyBindings[command]).toBeDefined(); + expect(Array.isArray(defaultKeyBindings[command])).toBe(true); + } + }); + + it('should have valid key binding structures', () => { + for (const [_, bindings] of Object.entries(defaultKeyBindings)) { + for (const binding of bindings) { + // Each binding should have either key or sequence, but not both + const hasKey = binding.key !== undefined; + const hasSequence = binding.sequence !== undefined; + + expect(hasKey || hasSequence).toBe(true); + expect(hasKey && hasSequence).toBe(false); + + // Modifier properties should be boolean or undefined + if (binding.ctrl !== undefined) { + expect(typeof binding.ctrl).toBe('boolean'); + } + if (binding.shift !== undefined) { + expect(typeof binding.shift).toBe('boolean'); + } + if (binding.command !== undefined) { + expect(typeof binding.command).toBe('boolean'); + } + if (binding.paste !== undefined) { + expect(typeof binding.paste).toBe('boolean'); + } + } + } + }); + + it('should export all required types', () => { + // Basic type checks + expect(typeof Command.HOME).toBe('string'); + expect(typeof Command.END).toBe('string'); + + // Config should be readonly + const config: KeyBindingConfig = defaultKeyBindings; + expect(config[Command.HOME]).toBeDefined(); + }); + }); +}); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts new file mode 100644 index 00000000..f6ba52e2 --- /dev/null +++ b/packages/cli/src/config/keyBindings.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Command enum for all available keyboard shortcuts + */ +export enum Command { + // Basic bindings + RETURN = 'return', + ESCAPE = 'escape', + + // Cursor movement + HOME = 'home', + END = 'end', + + // Text deletion + KILL_LINE_RIGHT = 'killLineRight', + KILL_LINE_LEFT = 'killLineLeft', + CLEAR_INPUT = 'clearInput', + + // Screen control + CLEAR_SCREEN = 'clearScreen', + + // History navigation + HISTORY_UP = 'historyUp', + HISTORY_DOWN = 'historyDown', + NAVIGATION_UP = 'navigationUp', + NAVIGATION_DOWN = 'navigationDown', + + // Auto-completion + ACCEPT_SUGGESTION = 'acceptSuggestion', + + // Text input + SUBMIT = 'submit', + NEWLINE = 'newline', + + // External tools + OPEN_EXTERNAL_EDITOR = 'openExternalEditor', + PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage', + + // App level bindings + SHOW_ERROR_DETAILS = 'showErrorDetails', + TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions', + TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', + QUIT = 'quit', + EXIT = 'exit', + SHOW_MORE_LINES = 'showMoreLines', + + // Shell commands + REVERSE_SEARCH = 'reverseSearch', + SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', + ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', +} + +/** + * Data-driven key binding structure for user configuration + */ +export interface KeyBinding { + /** The key name (e.g., 'a', 'return', 'tab', 'escape') */ + key?: string; + /** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */ + sequence?: string; + /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + ctrl?: boolean; + /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + shift?: boolean; + /** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + command?: boolean; + /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ + paste?: boolean; +} + +/** + * Configuration type mapping commands to their key bindings + */ +export type KeyBindingConfig = { + readonly [C in Command]: readonly KeyBinding[]; +}; + +/** + * Default key binding configuration + * Matches the original hard-coded logic exactly + */ +export const defaultKeyBindings: KeyBindingConfig = { + // Basic bindings + [Command.RETURN]: [{ key: 'return' }], + // Original: key.name === 'escape' + [Command.ESCAPE]: [{ key: 'escape' }], + + // Cursor movement + // Original: key.ctrl && key.name === 'a' + [Command.HOME]: [{ key: 'a', ctrl: true }], + // Original: key.ctrl && key.name === 'e' + [Command.END]: [{ key: 'e', ctrl: true }], + + // Text deletion + // Original: key.ctrl && key.name === 'k' + [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], + // Original: key.ctrl && key.name === 'u' + [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], + // Original: key.ctrl && key.name === 'c' + [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], + + // Screen control + // Original: key.ctrl && key.name === 'l' + [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], + + // History navigation + // Original: key.ctrl && key.name === 'p' + [Command.HISTORY_UP]: [{ key: 'p', ctrl: true }], + // Original: key.ctrl && key.name === 'n' + [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }], + // Original: key.name === 'up' + [Command.NAVIGATION_UP]: [{ key: 'up' }], + // Original: key.name === 'down' + [Command.NAVIGATION_DOWN]: [{ key: 'down' }], + + // Auto-completion + // Original: key.name === 'tab' || (key.name === 'return' && !key.ctrl) + [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], + + // Text input + // Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste + [Command.SUBMIT]: [ + { + key: 'return', + ctrl: false, + command: false, + paste: false, + }, + ], + // Original: key.name === 'return' && (key.ctrl || key.meta || key.paste) + // Split into multiple data-driven bindings + [Command.NEWLINE]: [ + { key: 'return', ctrl: true }, + { key: 'return', command: true }, + { key: 'return', paste: true }, + ], + + // External tools + // Original: key.ctrl && (key.name === 'x' || key.sequence === '\x18') + [Command.OPEN_EXTERNAL_EDITOR]: [ + { key: 'x', ctrl: true }, + { sequence: '\x18', ctrl: true }, + ], + // Original: key.ctrl && key.name === 'v' + [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], + + // App level bindings + // Original: key.ctrl && key.name === 'o' + [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], + // Original: key.ctrl && key.name === 't' + [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], + // Original: key.ctrl && key.name === 'e' + [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'e', ctrl: true }], + // Original: key.ctrl && (key.name === 'c' || key.name === 'C') + [Command.QUIT]: [ + { key: 'c', ctrl: true }, + { key: 'C', ctrl: true }, + ], + // Original: key.ctrl && (key.name === 'd' || key.name === 'D') + [Command.EXIT]: [ + { key: 'd', ctrl: true }, + { key: 'D', ctrl: true }, + ], + // Original: key.ctrl && key.name === 's' + [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], + + // Shell commands + // Original: key.ctrl && key.name === 'r' + [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], + // Original: key.name === 'return' && !key.ctrl + // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste + [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], + // Original: key.name === 'tab' + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], +}; diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e3c77ad0..7ee9405f 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -13,8 +13,6 @@ import { Text, useStdin, useStdout, - useInput, - type Key as InkKeyType, } from 'ink'; import { StreamingState, type HistoryItem, MessageType } from './types.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; @@ -81,6 +79,8 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js'; import { useVim } from './hooks/vim.js'; +import { useKeypress, Key } from './hooks/useKeypress.js'; +import { keyMatchers, Command } from './keyMatchers.js'; import * as fs from 'fs'; import { UpdateNotification } from './components/UpdateNotification.js'; import { @@ -613,50 +613,71 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { [handleSlashCommand], ); - useInput((input: string, key: InkKeyType) => { - let enteringConstrainHeightMode = false; - if (!constrainHeight) { - // Automatically re-enter constrain height mode if the user types - // anything. When constrainHeight==false, the user will experience - // significant flickering so it is best to disable it immediately when - // the user starts interacting with the app. - enteringConstrainHeightMode = true; - setConstrainHeight(true); - } + const handleGlobalKeypress = useCallback( + (key: Key) => { + let enteringConstrainHeightMode = false; + if (!constrainHeight) { + enteringConstrainHeightMode = true; + setConstrainHeight(true); + } - if (key.ctrl && input === 'o') { - setShowErrorDetails((prev) => !prev); - } else if (key.ctrl && input === 't') { - const newValue = !showToolDescriptions; - setShowToolDescriptions(newValue); + if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { + setShowErrorDetails((prev) => !prev); + } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { + const newValue = !showToolDescriptions; + setShowToolDescriptions(newValue); - const mcpServers = config.getMcpServers(); - if (Object.keys(mcpServers || {}).length > 0) { - handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); + const mcpServers = config.getMcpServers(); + if (Object.keys(mcpServers || {}).length > 0) { + handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); + } + } else if ( + keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) && + config.getIdeMode() && + ideContextState + ) { + // Show IDE status when in IDE mode and context is available. + handleSlashCommand('/ide status'); + } else if (keyMatchers[Command.QUIT](key)) { + // When authenticating, let AuthInProgress component handle Ctrl+C. + if (isAuthenticating) { + return; + } + handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); + } else if (keyMatchers[Command.EXIT](key)) { + if (buffer.text.length > 0) { + return; + } + handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); + } else if ( + keyMatchers[Command.SHOW_MORE_LINES](key) && + !enteringConstrainHeightMode + ) { + setConstrainHeight(false); } - } else if ( - key.ctrl && - input === 'e' && - config.getIdeMode() && - ideContextState - ) { - handleSlashCommand('/ide status'); - } else if (key.ctrl && (input === 'c' || input === 'C')) { - if (isAuthenticating) { - // Let AuthInProgress component handle the input. - return; - } - handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); - } else if (key.ctrl && (input === 'd' || input === 'D')) { - if (buffer.text.length > 0) { - // Do nothing if there is text in the input. - return; - } - handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); - } else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) { - setConstrainHeight(false); - } - }); + }, + [ + constrainHeight, + setConstrainHeight, + setShowErrorDetails, + showToolDescriptions, + setShowToolDescriptions, + config, + ideContextState, + handleExit, + ctrlCPressedOnce, + setCtrlCPressedOnce, + ctrlCTimerRef, + buffer.text.length, + ctrlDPressedOnce, + setCtrlDPressedOnce, + ctrlDTimerRef, + handleSlashCommand, + isAuthenticating, + ], + ); + + useKeypress(handleGlobalKeypress, { isActive: true }); useEffect(() => { if (config) { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 7250afea..78b3b96b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import { useKeypress, Key } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; import { @@ -221,7 +222,7 @@ export const InputPrompt: React.FC = ({ return; } - if (key.name === 'escape') { + if (keyMatchers[Command.ESCAPE](key)) { if (reverseSearchActive) { setReverseSearchActive(false); reverseSearchCompletion.resetCompletionState(); @@ -234,7 +235,6 @@ export const InputPrompt: React.FC = ({ buffer.moveToOffset(offset); return; } - if (shellModeActive) { setShellModeActive(false); return; @@ -246,14 +246,14 @@ export const InputPrompt: React.FC = ({ } } - if (shellModeActive && key.ctrl && key.name === 'r') { + if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { setReverseSearchActive(true); setTextBeforeReverseSearch(buffer.text); setCursorPosition(buffer.cursor); return; } - if (key.ctrl && key.name === 'l') { + if (keyMatchers[Command.CLEAR_SCREEN](key)) { onClearScreen(); return; } @@ -268,15 +268,15 @@ export const InputPrompt: React.FC = ({ } = reverseSearchCompletion; if (showSuggestions) { - if (key.name === 'up') { + if (keyMatchers[Command.NAVIGATION_UP](key)) { navigateUp(); return; } - if (key.name === 'down') { + if (keyMatchers[Command.NAVIGATION_DOWN](key)) { navigateDown(); return; } - if (key.name === 'tab') { + if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) { reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex); reverseSearchCompletion.resetCompletionState(); setReverseSearchActive(false); @@ -284,7 +284,7 @@ export const InputPrompt: React.FC = ({ } } - if (key.name === 'return' && !key.ctrl) { + if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) { const textToSubmit = showSuggestions && activeSuggestionIndex > -1 ? suggestions[activeSuggestionIndex].value @@ -296,30 +296,39 @@ export const InputPrompt: React.FC = ({ } // Prevent up/down from falling through to regular history navigation - if (key.name === 'up' || key.name === 'down') { + if ( + keyMatchers[Command.NAVIGATION_UP](key) || + keyMatchers[Command.NAVIGATION_DOWN](key) + ) { return; } } // If the command is a perfect match, pressing enter should execute it. - if (completion.isPerfectMatch && key.name === 'return') { + if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) { handleSubmitAndClear(buffer.text); return; } if (completion.showSuggestions) { if (completion.suggestions.length > 1) { - if (key.name === 'up' || (key.ctrl && key.name === 'p')) { + if ( + keyMatchers[Command.NAVIGATION_UP](key) || + keyMatchers[Command.HISTORY_UP](key) + ) { completion.navigateUp(); return; } - if (key.name === 'down' || (key.ctrl && key.name === 'n')) { + if ( + keyMatchers[Command.NAVIGATION_DOWN](key) || + keyMatchers[Command.HISTORY_DOWN](key) + ) { completion.navigateDown(); return; } } - if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) { + if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) { if (completion.suggestions.length > 0) { const targetIndex = completion.activeSuggestionIndex === -1 @@ -334,17 +343,17 @@ export const InputPrompt: React.FC = ({ } if (!shellModeActive) { - if (key.ctrl && key.name === 'p') { + if (keyMatchers[Command.HISTORY_UP](key)) { inputHistory.navigateUp(); return; } - if (key.ctrl && key.name === 'n') { + if (keyMatchers[Command.HISTORY_DOWN](key)) { inputHistory.navigateDown(); return; } // Handle arrow-up/down for history on single-line or at edges if ( - key.name === 'up' && + keyMatchers[Command.NAVIGATION_UP](key) && (buffer.allVisualLines.length === 1 || (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) ) { @@ -352,7 +361,7 @@ export const InputPrompt: React.FC = ({ return; } if ( - key.name === 'down' && + keyMatchers[Command.NAVIGATION_DOWN](key) && (buffer.allVisualLines.length === 1 || buffer.visualCursor[0] === buffer.allVisualLines.length - 1) ) { @@ -360,18 +369,20 @@ export const InputPrompt: React.FC = ({ return; } } else { - if (key.name === 'up') { + // Shell History Navigation + if (keyMatchers[Command.NAVIGATION_UP](key)) { const prevCommand = shellHistory.getPreviousCommand(); if (prevCommand !== null) buffer.setText(prevCommand); return; } - if (key.name === 'down') { + if (keyMatchers[Command.NAVIGATION_DOWN](key)) { const nextCommand = shellHistory.getNextCommand(); if (nextCommand !== null) buffer.setText(nextCommand); return; } } - if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) { + + if (keyMatchers[Command.SUBMIT](key)) { if (buffer.text.trim()) { const [row, col] = buffer.cursor; const line = buffer.lines[row]; @@ -387,23 +398,23 @@ export const InputPrompt: React.FC = ({ } // Newline insertion - if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) { + if (keyMatchers[Command.NEWLINE](key)) { buffer.newline(); return; } // Ctrl+A (Home) / Ctrl+E (End) - if (key.ctrl && key.name === 'a') { + if (keyMatchers[Command.HOME](key)) { buffer.move('home'); return; } - if (key.ctrl && key.name === 'e') { + if (keyMatchers[Command.END](key)) { buffer.move('end'); buffer.moveToOffset(cpLen(buffer.text)); return; } // Ctrl+C (Clear input) - if (key.ctrl && key.name === 'c') { + if (keyMatchers[Command.CLEAR_INPUT](key)) { if (buffer.text.length > 0) { buffer.setText(''); resetCompletionState(); @@ -413,24 +424,23 @@ export const InputPrompt: React.FC = ({ } // Kill line commands - if (key.ctrl && key.name === 'k') { + if (keyMatchers[Command.KILL_LINE_RIGHT](key)) { buffer.killLineRight(); return; } - if (key.ctrl && key.name === 'u') { + if (keyMatchers[Command.KILL_LINE_LEFT](key)) { buffer.killLineLeft(); return; } // External editor - const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18'); - if (isCtrlX) { + if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { buffer.openInExternalEditor(); return; } // Ctrl+V for clipboard image paste - if (key.ctrl && key.name === 'v') { + if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) { handleClipboardImage(); return; } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts new file mode 100644 index 00000000..16951e79 --- /dev/null +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { keyMatchers, Command, createKeyMatchers } from './keyMatchers.js'; +import { KeyBindingConfig, defaultKeyBindings } from '../config/keyBindings.js'; +import type { Key } from './hooks/useKeypress.js'; + +describe('keyMatchers', () => { + const createKey = (name: string, mods: Partial = {}): Key => ({ + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: name, + ...mods, + }); + + // Original hard-coded logic (for comparison) + const originalMatchers = { + [Command.HOME]: (key: Key) => key.ctrl && key.name === 'a', + [Command.END]: (key: Key) => key.ctrl && key.name === 'e', + [Command.KILL_LINE_RIGHT]: (key: Key) => key.ctrl && key.name === 'k', + [Command.KILL_LINE_LEFT]: (key: Key) => key.ctrl && key.name === 'u', + [Command.CLEAR_INPUT]: (key: Key) => key.ctrl && key.name === 'c', + [Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l', + [Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p', + [Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n', + [Command.NAVIGATION_UP]: (key: Key) => key.name === 'up', + [Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down', + [Command.ACCEPT_SUGGESTION]: (key: Key) => + key.name === 'tab' || (key.name === 'return' && !key.ctrl), + [Command.ESCAPE]: (key: Key) => key.name === 'escape', + [Command.SUBMIT]: (key: Key) => + key.name === 'return' && !key.ctrl && !key.meta && !key.paste, + [Command.NEWLINE]: (key: Key) => + key.name === 'return' && (key.ctrl || key.meta || key.paste), + [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => + key.ctrl && (key.name === 'x' || key.sequence === '\x18'), + [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v', + [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.ctrl && key.name === 'o', + [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) => + key.ctrl && key.name === 't', + [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => + key.ctrl && key.name === 'e', + [Command.QUIT]: (key: Key) => + key.ctrl && (key.name === 'c' || key.name === 'C'), + [Command.EXIT]: (key: Key) => + key.ctrl && (key.name === 'd' || key.name === 'D'), + [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', + [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', + [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => + key.name === 'return' && !key.ctrl, + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: (key: Key) => + key.name === 'tab', + }; + + // Test data for each command with positive and negative test cases + const testCases = [ + // Basic bindings + { + command: Command.ESCAPE, + positive: [createKey('escape'), createKey('escape', { ctrl: true })], + negative: [createKey('e'), createKey('esc')], + }, + + // Cursor movement + { + command: Command.HOME, + positive: [createKey('a', { ctrl: true })], + negative: [ + createKey('a'), + createKey('a', { shift: true }), + createKey('b', { ctrl: true }), + ], + }, + { + command: Command.END, + positive: [createKey('e', { ctrl: true })], + negative: [ + createKey('e'), + createKey('e', { shift: true }), + createKey('a', { ctrl: true }), + ], + }, + + // Text deletion + { + command: Command.KILL_LINE_RIGHT, + positive: [createKey('k', { ctrl: true })], + negative: [createKey('k'), createKey('l', { ctrl: true })], + }, + { + command: Command.KILL_LINE_LEFT, + positive: [createKey('u', { ctrl: true })], + negative: [createKey('u'), createKey('k', { ctrl: true })], + }, + { + command: Command.CLEAR_INPUT, + positive: [createKey('c', { ctrl: true })], + negative: [createKey('c'), createKey('k', { ctrl: true })], + }, + + // Screen control + { + command: Command.CLEAR_SCREEN, + positive: [createKey('l', { ctrl: true })], + negative: [createKey('l'), createKey('k', { ctrl: true })], + }, + + // History navigation + { + command: Command.HISTORY_UP, + positive: [createKey('p', { ctrl: true })], + negative: [createKey('p'), createKey('up')], + }, + { + command: Command.HISTORY_DOWN, + positive: [createKey('n', { ctrl: true })], + negative: [createKey('n'), createKey('down')], + }, + { + command: Command.NAVIGATION_UP, + positive: [createKey('up'), createKey('up', { ctrl: true })], + negative: [createKey('p'), createKey('u')], + }, + { + command: Command.NAVIGATION_DOWN, + positive: [createKey('down'), createKey('down', { ctrl: true })], + negative: [createKey('n'), createKey('d')], + }, + + // Auto-completion + { + command: Command.ACCEPT_SUGGESTION, + positive: [createKey('tab'), createKey('return')], + negative: [createKey('return', { ctrl: true }), createKey('space')], + }, + + // Text input + { + command: Command.SUBMIT, + positive: [createKey('return')], + negative: [ + createKey('return', { ctrl: true }), + createKey('return', { meta: true }), + createKey('return', { paste: true }), + ], + }, + { + command: Command.NEWLINE, + positive: [ + createKey('return', { ctrl: true }), + createKey('return', { meta: true }), + createKey('return', { paste: true }), + ], + negative: [createKey('return'), createKey('n')], + }, + + // External tools + { + command: Command.OPEN_EXTERNAL_EDITOR, + positive: [ + createKey('x', { ctrl: true }), + { ...createKey('\x18'), sequence: '\x18', ctrl: true }, + ], + negative: [createKey('x'), createKey('c', { ctrl: true })], + }, + { + command: Command.PASTE_CLIPBOARD_IMAGE, + positive: [createKey('v', { ctrl: true })], + negative: [createKey('v'), createKey('c', { ctrl: true })], + }, + + // App level bindings + { + command: Command.SHOW_ERROR_DETAILS, + positive: [createKey('o', { ctrl: true })], + negative: [createKey('o'), createKey('e', { ctrl: true })], + }, + { + command: Command.TOGGLE_TOOL_DESCRIPTIONS, + positive: [createKey('t', { ctrl: true })], + negative: [createKey('t'), createKey('s', { ctrl: true })], + }, + { + command: Command.TOGGLE_IDE_CONTEXT_DETAIL, + positive: [createKey('e', { ctrl: true })], + negative: [createKey('e'), createKey('t', { ctrl: true })], + }, + { + command: Command.QUIT, + positive: [ + createKey('c', { ctrl: true }), + createKey('C', { ctrl: true }), + ], + negative: [createKey('c'), createKey('d', { ctrl: true })], + }, + { + command: Command.EXIT, + positive: [ + createKey('d', { ctrl: true }), + createKey('D', { ctrl: true }), + ], + negative: [createKey('d'), createKey('c', { ctrl: true })], + }, + { + command: Command.SHOW_MORE_LINES, + positive: [createKey('s', { ctrl: true })], + negative: [createKey('s'), createKey('l', { ctrl: true })], + }, + + // Shell commands + { + command: Command.REVERSE_SEARCH, + positive: [createKey('r', { ctrl: true })], + negative: [createKey('r'), createKey('s', { ctrl: true })], + }, + { + command: Command.SUBMIT_REVERSE_SEARCH, + positive: [createKey('return')], + negative: [createKey('return', { ctrl: true }), createKey('tab')], + }, + { + command: Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, + positive: [createKey('tab'), createKey('tab', { ctrl: true })], + negative: [createKey('return'), createKey('space')], + }, + ]; + + describe('Data-driven key binding matches original logic', () => { + testCases.forEach(({ command, positive, negative }) => { + it(`should match ${command} correctly`, () => { + positive.forEach((key) => { + expect( + keyMatchers[command](key), + `Expected ${command} to match ${JSON.stringify(key)}`, + ).toBe(true); + expect( + originalMatchers[command](key), + `Original matcher should also match ${JSON.stringify(key)}`, + ).toBe(true); + }); + + negative.forEach((key) => { + expect( + keyMatchers[command](key), + `Expected ${command} to NOT match ${JSON.stringify(key)}`, + ).toBe(false); + expect( + originalMatchers[command](key), + `Original matcher should also NOT match ${JSON.stringify(key)}`, + ).toBe(false); + }); + }); + }); + + it('should properly handle ACCEPT_SUGGESTION_REVERSE_SEARCH cases', () => { + expect( + keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]( + createKey('return', { ctrl: true }), + ), + ).toBe(false); // ctrl must be false + expect( + keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](createKey('tab')), + ).toBe(true); + expect( + keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]( + createKey('tab', { ctrl: true }), + ), + ).toBe(true); // modifiers ignored + }); + }); + + describe('Custom key bindings', () => { + it('should work with custom configuration', () => { + const customConfig: KeyBindingConfig = { + ...defaultKeyBindings, + [Command.HOME]: [{ key: 'h', ctrl: true }, { key: '0' }], + }; + + const customMatchers = createKeyMatchers(customConfig); + + expect(customMatchers[Command.HOME](createKey('h', { ctrl: true }))).toBe( + true, + ); + expect(customMatchers[Command.HOME](createKey('0'))).toBe(true); + expect(customMatchers[Command.HOME](createKey('a', { ctrl: true }))).toBe( + false, + ); + }); + + it('should support multiple key bindings for same command', () => { + const config: KeyBindingConfig = { + ...defaultKeyBindings, + [Command.QUIT]: [ + { key: 'q', ctrl: true }, + { key: 'q', command: true }, + ], + }; + + const matchers = createKeyMatchers(config); + expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true); + expect(matchers[Command.QUIT](createKey('q', { meta: true }))).toBe(true); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty binding arrays', () => { + const config: KeyBindingConfig = { + ...defaultKeyBindings, + [Command.HOME]: [], + }; + + const matchers = createKeyMatchers(config); + expect(matchers[Command.HOME](createKey('a', { ctrl: true }))).toBe( + false, + ); + }); + + it('should handle case sensitivity', () => { + const config: KeyBindingConfig = { + ...defaultKeyBindings, + [Command.QUIT]: [{ key: 'Q', ctrl: true }], + }; + + const matchers = createKeyMatchers(config); + expect(matchers[Command.QUIT](createKey('Q', { ctrl: true }))).toBe(true); + expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe( + false, + ); + }); + }); +}); diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts new file mode 100644 index 00000000..651343af --- /dev/null +++ b/packages/cli/src/ui/keyMatchers.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Key } from './hooks/useKeypress.js'; +import { + Command, + KeyBinding, + KeyBindingConfig, + defaultKeyBindings, +} from '../config/keyBindings.js'; + +/** + * Matches a KeyBinding against an actual Key press + * Pure data-driven matching logic + */ +function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean { + // Either key name or sequence must match (but not both should be defined) + let keyMatches = false; + + if (keyBinding.key !== undefined) { + keyMatches = keyBinding.key === key.name; + } else if (keyBinding.sequence !== undefined) { + keyMatches = keyBinding.sequence === key.sequence; + } else { + // Neither key nor sequence defined - invalid binding + return false; + } + + if (!keyMatches) { + return false; + } + + // Check modifiers - follow original logic: + // undefined = ignore this modifier (original behavior) + // true = modifier must be pressed + // false = modifier must NOT be pressed + + if (keyBinding.ctrl !== undefined && key.ctrl !== keyBinding.ctrl) { + return false; + } + + if (keyBinding.shift !== undefined && key.shift !== keyBinding.shift) { + return false; + } + + if (keyBinding.command !== undefined && key.meta !== keyBinding.command) { + return false; + } + + if (keyBinding.paste !== undefined && key.paste !== keyBinding.paste) { + return false; + } + + return true; +} + +/** + * Checks if a key matches any of the bindings for a command + */ +function matchCommand( + command: Command, + key: Key, + config: KeyBindingConfig = defaultKeyBindings, +): boolean { + const bindings = config[command]; + return bindings.some((binding) => matchKeyBinding(binding, key)); +} + +/** + * Key matcher function type + */ +type KeyMatcher = (key: Key) => boolean; + +/** + * Type for key matchers mapped to Command enum + */ +export type KeyMatchers = { + readonly [C in Command]: KeyMatcher; +}; + +/** + * Creates key matchers from a key binding configuration + */ +export function createKeyMatchers( + config: KeyBindingConfig = defaultKeyBindings, +): KeyMatchers { + const matchers = {} as { [C in Command]: KeyMatcher }; + + for (const command of Object.values(Command)) { + matchers[command] = (key: Key) => matchCommand(command, key, config); + } + + return matchers as KeyMatchers; +} + +/** + * Default key binding matchers using the default configuration + */ +export const keyMatchers: KeyMatchers = createKeyMatchers(defaultKeyBindings); + +// Re-export Command for convenience +export { Command };