Centralize Key Binding Logic and Refactor (Reopen) (#5356)
Co-authored-by: Lee-WonJun <10369528+Lee-WonJun@users.noreply.github.com>
This commit is contained in:
parent
6487cc1689
commit
b8084ba815
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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' }],
|
||||||
|
};
|
|
@ -13,8 +13,6 @@ import {
|
||||||
Text,
|
Text,
|
||||||
useStdin,
|
useStdin,
|
||||||
useStdout,
|
useStdout,
|
||||||
useInput,
|
|
||||||
type Key as InkKeyType,
|
|
||||||
} from 'ink';
|
} from 'ink';
|
||||||
import { StreamingState, type HistoryItem, MessageType } from './types.js';
|
import { StreamingState, type HistoryItem, MessageType } from './types.js';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.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 { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
||||||
import { useVim } from './hooks/vim.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 * as fs from 'fs';
|
||||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||||
import {
|
import {
|
||||||
|
@ -613,50 +613,71 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
[handleSlashCommand],
|
[handleSlashCommand],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((input: string, key: InkKeyType) => {
|
const handleGlobalKeypress = useCallback(
|
||||||
let enteringConstrainHeightMode = false;
|
(key: Key) => {
|
||||||
if (!constrainHeight) {
|
let enteringConstrainHeightMode = false;
|
||||||
// Automatically re-enter constrain height mode if the user types
|
if (!constrainHeight) {
|
||||||
// anything. When constrainHeight==false, the user will experience
|
enteringConstrainHeightMode = true;
|
||||||
// significant flickering so it is best to disable it immediately when
|
setConstrainHeight(true);
|
||||||
// the user starts interacting with the app.
|
}
|
||||||
enteringConstrainHeightMode = true;
|
|
||||||
setConstrainHeight(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.ctrl && input === 'o') {
|
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||||
setShowErrorDetails((prev) => !prev);
|
setShowErrorDetails((prev) => !prev);
|
||||||
} else if (key.ctrl && input === 't') {
|
} else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
|
||||||
const newValue = !showToolDescriptions;
|
const newValue = !showToolDescriptions;
|
||||||
setShowToolDescriptions(newValue);
|
setShowToolDescriptions(newValue);
|
||||||
|
|
||||||
const mcpServers = config.getMcpServers();
|
const mcpServers = config.getMcpServers();
|
||||||
if (Object.keys(mcpServers || {}).length > 0) {
|
if (Object.keys(mcpServers || {}).length > 0) {
|
||||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
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' &&
|
constrainHeight,
|
||||||
config.getIdeMode() &&
|
setConstrainHeight,
|
||||||
ideContextState
|
setShowErrorDetails,
|
||||||
) {
|
showToolDescriptions,
|
||||||
handleSlashCommand('/ide status');
|
setShowToolDescriptions,
|
||||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
config,
|
||||||
if (isAuthenticating) {
|
ideContextState,
|
||||||
// Let AuthInProgress component handle the input.
|
handleExit,
|
||||||
return;
|
ctrlCPressedOnce,
|
||||||
}
|
setCtrlCPressedOnce,
|
||||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
ctrlCTimerRef,
|
||||||
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
buffer.text.length,
|
||||||
if (buffer.text.length > 0) {
|
ctrlDPressedOnce,
|
||||||
// Do nothing if there is text in the input.
|
setCtrlDPressedOnce,
|
||||||
return;
|
ctrlDTimerRef,
|
||||||
}
|
handleSlashCommand,
|
||||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
isAuthenticating,
|
||||||
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
|
],
|
||||||
setConstrainHeight(false);
|
);
|
||||||
}
|
|
||||||
});
|
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
|
@ -221,7 +222,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'escape') {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
if (reverseSearchActive) {
|
if (reverseSearchActive) {
|
||||||
setReverseSearchActive(false);
|
setReverseSearchActive(false);
|
||||||
reverseSearchCompletion.resetCompletionState();
|
reverseSearchCompletion.resetCompletionState();
|
||||||
|
@ -234,7 +235,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
buffer.moveToOffset(offset);
|
buffer.moveToOffset(offset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive) {
|
if (shellModeActive) {
|
||||||
setShellModeActive(false);
|
setShellModeActive(false);
|
||||||
return;
|
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);
|
setReverseSearchActive(true);
|
||||||
setTextBeforeReverseSearch(buffer.text);
|
setTextBeforeReverseSearch(buffer.text);
|
||||||
setCursorPosition(buffer.cursor);
|
setCursorPosition(buffer.cursor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.ctrl && key.name === 'l') {
|
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||||
onClearScreen();
|
onClearScreen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -268,15 +268,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
} = reverseSearchCompletion;
|
} = reverseSearchCompletion;
|
||||||
|
|
||||||
if (showSuggestions) {
|
if (showSuggestions) {
|
||||||
if (key.name === 'up') {
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||||
navigateUp();
|
navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'down') {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
navigateDown();
|
navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'tab') {
|
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||||
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
|
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
|
||||||
reverseSearchCompletion.resetCompletionState();
|
reverseSearchCompletion.resetCompletionState();
|
||||||
setReverseSearchActive(false);
|
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 =
|
const textToSubmit =
|
||||||
showSuggestions && activeSuggestionIndex > -1
|
showSuggestions && activeSuggestionIndex > -1
|
||||||
? suggestions[activeSuggestionIndex].value
|
? suggestions[activeSuggestionIndex].value
|
||||||
|
@ -296,30 +296,39 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent up/down from falling through to regular history navigation
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the command is a perfect match, pressing enter should execute it.
|
// 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);
|
handleSubmitAndClear(buffer.text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
if (completion.suggestions.length > 1) {
|
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();
|
completion.navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
|
if (
|
||||||
|
keyMatchers[Command.NAVIGATION_DOWN](key) ||
|
||||||
|
keyMatchers[Command.HISTORY_DOWN](key)
|
||||||
|
) {
|
||||||
completion.navigateDown();
|
completion.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
|
||||||
if (completion.suggestions.length > 0) {
|
if (completion.suggestions.length > 0) {
|
||||||
const targetIndex =
|
const targetIndex =
|
||||||
completion.activeSuggestionIndex === -1
|
completion.activeSuggestionIndex === -1
|
||||||
|
@ -334,17 +343,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shellModeActive) {
|
if (!shellModeActive) {
|
||||||
if (key.ctrl && key.name === 'p') {
|
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||||
inputHistory.navigateUp();
|
inputHistory.navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.ctrl && key.name === 'n') {
|
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||||
inputHistory.navigateDown();
|
inputHistory.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Handle arrow-up/down for history on single-line or at edges
|
// Handle arrow-up/down for history on single-line or at edges
|
||||||
if (
|
if (
|
||||||
key.name === 'up' &&
|
keyMatchers[Command.NAVIGATION_UP](key) &&
|
||||||
(buffer.allVisualLines.length === 1 ||
|
(buffer.allVisualLines.length === 1 ||
|
||||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||||
) {
|
) {
|
||||||
|
@ -352,7 +361,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
key.name === 'down' &&
|
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||||
(buffer.allVisualLines.length === 1 ||
|
(buffer.allVisualLines.length === 1 ||
|
||||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||||
) {
|
) {
|
||||||
|
@ -360,18 +369,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (key.name === 'up') {
|
// Shell History Navigation
|
||||||
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||||
const prevCommand = shellHistory.getPreviousCommand();
|
const prevCommand = shellHistory.getPreviousCommand();
|
||||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'down') {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
const nextCommand = shellHistory.getNextCommand();
|
const nextCommand = shellHistory.getNextCommand();
|
||||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
|
||||||
|
if (keyMatchers[Command.SUBMIT](key)) {
|
||||||
if (buffer.text.trim()) {
|
if (buffer.text.trim()) {
|
||||||
const [row, col] = buffer.cursor;
|
const [row, col] = buffer.cursor;
|
||||||
const line = buffer.lines[row];
|
const line = buffer.lines[row];
|
||||||
|
@ -387,23 +398,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newline insertion
|
// Newline insertion
|
||||||
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
|
if (keyMatchers[Command.NEWLINE](key)) {
|
||||||
buffer.newline();
|
buffer.newline();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+A (Home) / Ctrl+E (End)
|
// Ctrl+A (Home) / Ctrl+E (End)
|
||||||
if (key.ctrl && key.name === 'a') {
|
if (keyMatchers[Command.HOME](key)) {
|
||||||
buffer.move('home');
|
buffer.move('home');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.ctrl && key.name === 'e') {
|
if (keyMatchers[Command.END](key)) {
|
||||||
buffer.move('end');
|
buffer.move('end');
|
||||||
buffer.moveToOffset(cpLen(buffer.text));
|
buffer.moveToOffset(cpLen(buffer.text));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+C (Clear input)
|
// Ctrl+C (Clear input)
|
||||||
if (key.ctrl && key.name === 'c') {
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
if (buffer.text.length > 0) {
|
if (buffer.text.length > 0) {
|
||||||
buffer.setText('');
|
buffer.setText('');
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
|
@ -413,24 +424,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill line commands
|
// Kill line commands
|
||||||
if (key.ctrl && key.name === 'k') {
|
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||||
buffer.killLineRight();
|
buffer.killLineRight();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.ctrl && key.name === 'u') {
|
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||||
buffer.killLineLeft();
|
buffer.killLineLeft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// External editor
|
// External editor
|
||||||
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
|
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||||
if (isCtrlX) {
|
|
||||||
buffer.openInExternalEditor();
|
buffer.openInExternalEditor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V for clipboard image paste
|
// Ctrl+V for clipboard image paste
|
||||||
if (key.ctrl && key.name === 'v') {
|
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
|
||||||
handleClipboardImage();
|
handleClipboardImage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 };
|
Loading…
Reference in New Issue