summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorLee Won Jun <[email protected]>2025-08-09 16:03:17 +0900
committerGitHub <[email protected]>2025-08-09 07:03:17 +0000
commitb8084ba8158b89facd49fd78a51abb80b1db54da (patch)
tree5fd41e255b5118d53798c29d9fad95478a1ed582 /packages/cli/src
parent6487cc16895976ef6c983f8beca08a64addb6688 (diff)
Centralize Key Binding Logic and Refactor (Reopen) (#5356)
Co-authored-by: Lee-WonJun <[email protected]>
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/keyBindings.test.ts62
-rw-r--r--packages/cli/src/config/keyBindings.ts180
-rw-r--r--packages/cli/src/ui/App.tsx107
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx68
-rw-r--r--packages/cli/src/ui/keyMatchers.test.ts338
-rw-r--r--packages/cli/src/ui/keyMatchers.ts105
6 files changed, 788 insertions, 72 deletions
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');
- }
- } 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;
+ 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);
}
- 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<InputPromptProps> = ({
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<InputPromptProps> = ({
buffer.moveToOffset(offset);
return;
}
-
if (shellModeActive) {
setShellModeActive(false);
return;
@@ -246,14 +246,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
- 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<InputPromptProps> = ({
} = 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<InputPromptProps> = ({
}
}
- 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<InputPromptProps> = ({
}
// 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<InputPromptProps> = ({
}
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<InputPromptProps> = ({
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<InputPromptProps> = ({
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<InputPromptProps> = ({
}
// 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<InputPromptProps> = ({
}
// 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> = {}): 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 };