mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 10:34:35 -07:00
refactor(ui): unify keybinding infrastructure and support string initialization (#21776)
This commit is contained in:
committed by
GitHub
parent
b89944c3a3
commit
215f8f3f15
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import {
|
||||
Command,
|
||||
commandCategories,
|
||||
commandDescriptions,
|
||||
defaultKeyBindings,
|
||||
KeyBinding,
|
||||
} from './keyBindings.js';
|
||||
|
||||
describe('KeyBinding', () => {
|
||||
describe('constructor', () => {
|
||||
it('should parse a simple key', () => {
|
||||
const binding = new KeyBinding('a');
|
||||
expect(binding.key).toBe('a');
|
||||
expect(binding.ctrl).toBe(false);
|
||||
expect(binding.shift).toBe(false);
|
||||
expect(binding.alt).toBe(false);
|
||||
expect(binding.cmd).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse ctrl+key', () => {
|
||||
const binding = new KeyBinding('ctrl+c');
|
||||
expect(binding.key).toBe('c');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse shift+key', () => {
|
||||
const binding = new KeyBinding('shift+z');
|
||||
expect(binding.key).toBe('z');
|
||||
expect(binding.shift).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse alt+key', () => {
|
||||
const binding = new KeyBinding('alt+left');
|
||||
expect(binding.key).toBe('left');
|
||||
expect(binding.alt).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse cmd+key', () => {
|
||||
const binding = new KeyBinding('cmd+f');
|
||||
expect(binding.key).toBe('f');
|
||||
expect(binding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle aliases (option/opt/meta)', () => {
|
||||
const optionBinding = new KeyBinding('option+b');
|
||||
expect(optionBinding.key).toBe('b');
|
||||
expect(optionBinding.alt).toBe(true);
|
||||
|
||||
const optBinding = new KeyBinding('opt+b');
|
||||
expect(optBinding.key).toBe('b');
|
||||
expect(optBinding.alt).toBe(true);
|
||||
|
||||
const metaBinding = new KeyBinding('meta+enter');
|
||||
expect(metaBinding.key).toBe('enter');
|
||||
expect(metaBinding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse multiple modifiers', () => {
|
||||
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
|
||||
expect(binding.key).toBe('x');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
expect(binding.shift).toBe(true);
|
||||
expect(binding.alt).toBe(true);
|
||||
expect(binding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const binding = new KeyBinding('CTRL+Shift+F');
|
||||
expect(binding.key).toBe('f');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
expect(binding.shift).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle named keys with modifiers', () => {
|
||||
const binding = new KeyBinding('ctrl+return');
|
||||
expect(binding.key).toBe('return');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid keys or typos in modifiers', () => {
|
||||
expect(() => new KeyBinding('ctrl+unknown')).toThrow(
|
||||
'Invalid keybinding key: "unknown" in "ctrl+unknown"',
|
||||
);
|
||||
expect(() => new KeyBinding('ctlr+a')).toThrow(
|
||||
'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for literal "+" as key (must use "=")', () => {
|
||||
// VS Code style peeling logic results in "+" as the remains
|
||||
expect(() => new KeyBinding('alt++')).toThrow(
|
||||
'Invalid keybinding key: "+" in "alt++"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command metadata', () => {
|
||||
const commandValues = Object.values(Command);
|
||||
|
||||
it('has a description entry for every command', () => {
|
||||
const describedCommands = Object.keys(commandDescriptions);
|
||||
expect(describedCommands.sort()).toEqual([...commandValues].sort());
|
||||
|
||||
for (const command of commandValues) {
|
||||
expect(typeof commandDescriptions[command]).toBe('string');
|
||||
expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('categorizes each command exactly once', () => {
|
||||
const seen = new Set<Command>();
|
||||
|
||||
for (const category of commandCategories) {
|
||||
expect(typeof category.title).toBe('string');
|
||||
expect(Array.isArray(category.commands)).toBe(true);
|
||||
|
||||
for (const command of category.commands) {
|
||||
expect(commandValues).toContain(command);
|
||||
expect(seen.has(command)).toBe(false);
|
||||
seen.add(command);
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(commandValues.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Command enum for all available keyboard shortcuts
|
||||
*/
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
export enum Command {
|
||||
// Basic Controls
|
||||
RETURN = 'basic.confirm',
|
||||
ESCAPE = 'basic.cancel',
|
||||
QUIT = 'basic.quit',
|
||||
EXIT = 'basic.exit',
|
||||
|
||||
// Cursor Movement
|
||||
HOME = 'cursor.home',
|
||||
END = 'cursor.end',
|
||||
MOVE_UP = 'cursor.up',
|
||||
MOVE_DOWN = 'cursor.down',
|
||||
MOVE_LEFT = 'cursor.left',
|
||||
MOVE_RIGHT = 'cursor.right',
|
||||
MOVE_WORD_LEFT = 'cursor.wordLeft',
|
||||
MOVE_WORD_RIGHT = 'cursor.wordRight',
|
||||
|
||||
// Editing
|
||||
KILL_LINE_RIGHT = 'edit.deleteRightAll',
|
||||
KILL_LINE_LEFT = 'edit.deleteLeftAll',
|
||||
CLEAR_INPUT = 'edit.clear',
|
||||
DELETE_WORD_BACKWARD = 'edit.deleteWordLeft',
|
||||
DELETE_WORD_FORWARD = 'edit.deleteWordRight',
|
||||
DELETE_CHAR_LEFT = 'edit.deleteLeft',
|
||||
DELETE_CHAR_RIGHT = 'edit.deleteRight',
|
||||
UNDO = 'edit.undo',
|
||||
REDO = 'edit.redo',
|
||||
|
||||
// Scrolling
|
||||
SCROLL_UP = 'scroll.up',
|
||||
SCROLL_DOWN = 'scroll.down',
|
||||
SCROLL_HOME = 'scroll.home',
|
||||
SCROLL_END = 'scroll.end',
|
||||
PAGE_UP = 'scroll.pageUp',
|
||||
PAGE_DOWN = 'scroll.pageDown',
|
||||
|
||||
// History & Search
|
||||
HISTORY_UP = 'history.previous',
|
||||
HISTORY_DOWN = 'history.next',
|
||||
REVERSE_SEARCH = 'history.search.start',
|
||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||
|
||||
// Navigation
|
||||
NAVIGATION_UP = 'nav.up',
|
||||
NAVIGATION_DOWN = 'nav.down',
|
||||
DIALOG_NAVIGATION_UP = 'nav.dialog.up',
|
||||
DIALOG_NAVIGATION_DOWN = 'nav.dialog.down',
|
||||
DIALOG_NEXT = 'nav.dialog.next',
|
||||
DIALOG_PREV = 'nav.dialog.previous',
|
||||
|
||||
// Suggestions & Completions
|
||||
ACCEPT_SUGGESTION = 'suggest.accept',
|
||||
COMPLETION_UP = 'suggest.focusPrevious',
|
||||
COMPLETION_DOWN = 'suggest.focusNext',
|
||||
EXPAND_SUGGESTION = 'suggest.expand',
|
||||
COLLAPSE_SUGGESTION = 'suggest.collapse',
|
||||
|
||||
// Text Input
|
||||
SUBMIT = 'input.submit',
|
||||
NEWLINE = 'input.newline',
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
|
||||
BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
|
||||
BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
|
||||
TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
|
||||
TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
|
||||
KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
|
||||
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
|
||||
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
SHOW_FULL_TODOS = 'app.showFullTodos',
|
||||
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
|
||||
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
|
||||
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
|
||||
TOGGLE_YOLO = 'app.toggleYolo',
|
||||
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
|
||||
SHOW_MORE_LINES = 'app.showMoreLines',
|
||||
EXPAND_PASTE = 'app.expandPaste',
|
||||
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
||||
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
||||
CLEAR_SCREEN = 'app.clearScreen',
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
}
|
||||
|
||||
/**
|
||||
* Data-driven key binding structure for user configuration
|
||||
*/
|
||||
export class KeyBinding {
|
||||
private static readonly VALID_KEYS = new Set([
|
||||
// Letters & Numbers
|
||||
...'abcdefghijklmnopqrstuvwxyz0123456789',
|
||||
// Punctuation
|
||||
'`',
|
||||
'-',
|
||||
'=',
|
||||
'[',
|
||||
']',
|
||||
'\\',
|
||||
';',
|
||||
"'",
|
||||
',',
|
||||
'.',
|
||||
'/',
|
||||
// Navigation & Actions
|
||||
'left',
|
||||
'up',
|
||||
'right',
|
||||
'down',
|
||||
'pageup',
|
||||
'pagedown',
|
||||
'end',
|
||||
'home',
|
||||
'tab',
|
||||
'enter',
|
||||
'escape',
|
||||
'space',
|
||||
'backspace',
|
||||
'delete',
|
||||
'pausebreak',
|
||||
'capslock',
|
||||
'insert',
|
||||
'numlock',
|
||||
'scrolllock',
|
||||
// Function Keys
|
||||
...Array.from({ length: 19 }, (_, i) => `f${i + 1}`),
|
||||
// Numpad
|
||||
...Array.from({ length: 10 }, (_, i) => `numpad${i}`),
|
||||
'numpad_multiply',
|
||||
'numpad_add',
|
||||
'numpad_separator',
|
||||
'numpad_subtract',
|
||||
'numpad_decimal',
|
||||
'numpad_divide',
|
||||
// Gemini CLI legacy/internal support
|
||||
'return',
|
||||
]);
|
||||
|
||||
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
|
||||
readonly key: string;
|
||||
readonly shift: boolean;
|
||||
readonly alt: boolean;
|
||||
readonly ctrl: boolean;
|
||||
readonly cmd: boolean;
|
||||
|
||||
constructor(pattern: string) {
|
||||
let remains = pattern.toLowerCase().trim();
|
||||
let shift = false;
|
||||
let alt = false;
|
||||
let ctrl = false;
|
||||
let cmd = false;
|
||||
|
||||
let matched: boolean;
|
||||
do {
|
||||
matched = false;
|
||||
if (remains.startsWith('ctrl+')) {
|
||||
ctrl = true;
|
||||
remains = remains.slice(5);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('shift+')) {
|
||||
shift = true;
|
||||
remains = remains.slice(6);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('alt+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('option+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(7);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('opt+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('cmd+')) {
|
||||
cmd = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('meta+')) {
|
||||
cmd = true;
|
||||
remains = remains.slice(5);
|
||||
matched = true;
|
||||
}
|
||||
} while (matched);
|
||||
|
||||
const key = remains;
|
||||
|
||||
if (!KeyBinding.VALID_KEYS.has(key)) {
|
||||
throw new Error(`Invalid keybinding key: "${key}" in "${pattern}"`);
|
||||
}
|
||||
|
||||
this.key = key;
|
||||
this.shift = shift;
|
||||
this.alt = alt;
|
||||
this.ctrl = ctrl;
|
||||
this.cmd = cmd;
|
||||
}
|
||||
|
||||
matches(key: Key): boolean {
|
||||
return (
|
||||
this.key === key.name &&
|
||||
!!key.shift === !!this.shift &&
|
||||
!!key.alt === !!this.alt &&
|
||||
!!key.ctrl === !!this.ctrl &&
|
||||
!!key.cmd === !!this.cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Controls
|
||||
[Command.RETURN]: [new KeyBinding('return')],
|
||||
[Command.ESCAPE]: [new KeyBinding('escape'), new KeyBinding('ctrl+[')],
|
||||
[Command.QUIT]: [new KeyBinding('ctrl+c')],
|
||||
[Command.EXIT]: [new KeyBinding('ctrl+d')],
|
||||
|
||||
// Cursor Movement
|
||||
[Command.HOME]: [new KeyBinding('ctrl+a'), new KeyBinding('home')],
|
||||
[Command.END]: [new KeyBinding('ctrl+e'), new KeyBinding('end')],
|
||||
[Command.MOVE_UP]: [new KeyBinding('up')],
|
||||
[Command.MOVE_DOWN]: [new KeyBinding('down')],
|
||||
[Command.MOVE_LEFT]: [new KeyBinding('left')],
|
||||
[Command.MOVE_RIGHT]: [new KeyBinding('right'), new KeyBinding('ctrl+f')],
|
||||
[Command.MOVE_WORD_LEFT]: [
|
||||
new KeyBinding('ctrl+left'),
|
||||
new KeyBinding('alt+left'),
|
||||
new KeyBinding('alt+b'),
|
||||
],
|
||||
[Command.MOVE_WORD_RIGHT]: [
|
||||
new KeyBinding('ctrl+right'),
|
||||
new KeyBinding('alt+right'),
|
||||
new KeyBinding('alt+f'),
|
||||
],
|
||||
|
||||
// Editing
|
||||
[Command.KILL_LINE_RIGHT]: [new KeyBinding('ctrl+k')],
|
||||
[Command.KILL_LINE_LEFT]: [new KeyBinding('ctrl+u')],
|
||||
[Command.CLEAR_INPUT]: [new KeyBinding('ctrl+c')],
|
||||
[Command.DELETE_WORD_BACKWARD]: [
|
||||
new KeyBinding('ctrl+backspace'),
|
||||
new KeyBinding('alt+backspace'),
|
||||
new KeyBinding('ctrl+w'),
|
||||
],
|
||||
[Command.DELETE_WORD_FORWARD]: [
|
||||
new KeyBinding('ctrl+delete'),
|
||||
new KeyBinding('alt+delete'),
|
||||
new KeyBinding('alt+d'),
|
||||
],
|
||||
[Command.DELETE_CHAR_LEFT]: [
|
||||
new KeyBinding('backspace'),
|
||||
new KeyBinding('ctrl+h'),
|
||||
],
|
||||
[Command.DELETE_CHAR_RIGHT]: [
|
||||
new KeyBinding('delete'),
|
||||
new KeyBinding('ctrl+d'),
|
||||
],
|
||||
[Command.UNDO]: [new KeyBinding('cmd+z'), new KeyBinding('alt+z')],
|
||||
[Command.REDO]: [
|
||||
new KeyBinding('ctrl+shift+z'),
|
||||
new KeyBinding('cmd+shift+z'),
|
||||
new KeyBinding('alt+shift+z'),
|
||||
],
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: [new KeyBinding('shift+up')],
|
||||
[Command.SCROLL_DOWN]: [new KeyBinding('shift+down')],
|
||||
[Command.SCROLL_HOME]: [
|
||||
new KeyBinding('ctrl+home'),
|
||||
new KeyBinding('shift+home'),
|
||||
],
|
||||
[Command.SCROLL_END]: [
|
||||
new KeyBinding('ctrl+end'),
|
||||
new KeyBinding('shift+end'),
|
||||
],
|
||||
[Command.PAGE_UP]: [new KeyBinding('pageup')],
|
||||
[Command.PAGE_DOWN]: [new KeyBinding('pagedown')],
|
||||
|
||||
// History & Search
|
||||
[Command.HISTORY_UP]: [new KeyBinding('ctrl+p')],
|
||||
[Command.HISTORY_DOWN]: [new KeyBinding('ctrl+n')],
|
||||
[Command.REVERSE_SEARCH]: [new KeyBinding('ctrl+r')],
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [new KeyBinding('return')],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [new KeyBinding('tab')],
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: [new KeyBinding('up')],
|
||||
[Command.NAVIGATION_DOWN]: [new KeyBinding('down')],
|
||||
// Navigation shortcuts appropriate for dialogs where we do not need to accept
|
||||
// text input.
|
||||
[Command.DIALOG_NAVIGATION_UP]: [new KeyBinding('up'), new KeyBinding('k')],
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: [
|
||||
new KeyBinding('down'),
|
||||
new KeyBinding('j'),
|
||||
],
|
||||
[Command.DIALOG_NEXT]: [new KeyBinding('tab')],
|
||||
[Command.DIALOG_PREV]: [new KeyBinding('shift+tab')],
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [
|
||||
new KeyBinding('tab'),
|
||||
new KeyBinding('return'),
|
||||
],
|
||||
[Command.COMPLETION_UP]: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
|
||||
[Command.COMPLETION_DOWN]: [new KeyBinding('down'), new KeyBinding('ctrl+n')],
|
||||
[Command.EXPAND_SUGGESTION]: [new KeyBinding('right')],
|
||||
[Command.COLLAPSE_SUGGESTION]: [new KeyBinding('left')],
|
||||
|
||||
// Text Input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
[Command.SUBMIT]: [new KeyBinding('return')],
|
||||
[Command.NEWLINE]: [
|
||||
new KeyBinding('ctrl+return'),
|
||||
new KeyBinding('cmd+return'),
|
||||
new KeyBinding('alt+return'),
|
||||
new KeyBinding('shift+return'),
|
||||
new KeyBinding('ctrl+j'),
|
||||
],
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: [new KeyBinding('ctrl+x')],
|
||||
[Command.PASTE_CLIPBOARD]: [
|
||||
new KeyBinding('ctrl+v'),
|
||||
new KeyBinding('cmd+v'),
|
||||
new KeyBinding('alt+v'),
|
||||
],
|
||||
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS]: [new KeyBinding('f12')],
|
||||
[Command.SHOW_FULL_TODOS]: [new KeyBinding('ctrl+t')],
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: [new KeyBinding('ctrl+g')],
|
||||
[Command.TOGGLE_MARKDOWN]: [new KeyBinding('alt+m')],
|
||||
[Command.TOGGLE_COPY_MODE]: [new KeyBinding('ctrl+s')],
|
||||
[Command.TOGGLE_YOLO]: [new KeyBinding('ctrl+y')],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [new KeyBinding('shift+tab')],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')],
|
||||
[Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')],
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('return')],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')],
|
||||
[Command.SHOW_MORE_LINES]: [new KeyBinding('ctrl+o')],
|
||||
[Command.EXPAND_PASTE]: [new KeyBinding('ctrl+o')],
|
||||
[Command.FOCUS_SHELL_INPUT]: [new KeyBinding('tab')],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [new KeyBinding('shift+tab')],
|
||||
[Command.CLEAR_SCREEN]: [new KeyBinding('ctrl+l')],
|
||||
[Command.RESTART_APP]: [new KeyBinding('r'), new KeyBinding('shift+r')],
|
||||
[Command.SUSPEND_APP]: [new KeyBinding('ctrl+z')],
|
||||
};
|
||||
|
||||
interface CommandCategory {
|
||||
readonly title: string;
|
||||
readonly commands: readonly Command[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation metadata for grouping commands in documentation or UI.
|
||||
*/
|
||||
export const commandCategories: readonly CommandCategory[] = [
|
||||
{
|
||||
title: 'Basic Controls',
|
||||
commands: [Command.RETURN, Command.ESCAPE, Command.QUIT, Command.EXIT],
|
||||
},
|
||||
{
|
||||
title: 'Cursor Movement',
|
||||
commands: [
|
||||
Command.HOME,
|
||||
Command.END,
|
||||
Command.MOVE_UP,
|
||||
Command.MOVE_DOWN,
|
||||
Command.MOVE_LEFT,
|
||||
Command.MOVE_RIGHT,
|
||||
Command.MOVE_WORD_LEFT,
|
||||
Command.MOVE_WORD_RIGHT,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Editing',
|
||||
commands: [
|
||||
Command.KILL_LINE_RIGHT,
|
||||
Command.KILL_LINE_LEFT,
|
||||
Command.CLEAR_INPUT,
|
||||
Command.DELETE_WORD_BACKWARD,
|
||||
Command.DELETE_WORD_FORWARD,
|
||||
Command.DELETE_CHAR_LEFT,
|
||||
Command.DELETE_CHAR_RIGHT,
|
||||
Command.UNDO,
|
||||
Command.REDO,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Scrolling',
|
||||
commands: [
|
||||
Command.SCROLL_UP,
|
||||
Command.SCROLL_DOWN,
|
||||
Command.SCROLL_HOME,
|
||||
Command.SCROLL_END,
|
||||
Command.PAGE_UP,
|
||||
Command.PAGE_DOWN,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'History & Search',
|
||||
commands: [
|
||||
Command.HISTORY_UP,
|
||||
Command.HISTORY_DOWN,
|
||||
Command.REVERSE_SEARCH,
|
||||
Command.SUBMIT_REVERSE_SEARCH,
|
||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Navigation',
|
||||
commands: [
|
||||
Command.NAVIGATION_UP,
|
||||
Command.NAVIGATION_DOWN,
|
||||
Command.DIALOG_NAVIGATION_UP,
|
||||
Command.DIALOG_NAVIGATION_DOWN,
|
||||
Command.DIALOG_NEXT,
|
||||
Command.DIALOG_PREV,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suggestions & Completions',
|
||||
commands: [
|
||||
Command.ACCEPT_SUGGESTION,
|
||||
Command.COMPLETION_UP,
|
||||
Command.COMPLETION_DOWN,
|
||||
Command.EXPAND_SUGGESTION,
|
||||
Command.COLLAPSE_SUGGESTION,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Text Input',
|
||||
commands: [
|
||||
Command.SUBMIT,
|
||||
Command.NEWLINE,
|
||||
Command.OPEN_EXTERNAL_EDITOR,
|
||||
Command.PASTE_CLIPBOARD,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'App Controls',
|
||||
commands: [
|
||||
Command.SHOW_ERROR_DETAILS,
|
||||
Command.SHOW_FULL_TODOS,
|
||||
Command.SHOW_IDE_CONTEXT_DETAIL,
|
||||
Command.TOGGLE_MARKDOWN,
|
||||
Command.TOGGLE_COPY_MODE,
|
||||
Command.TOGGLE_YOLO,
|
||||
Command.CYCLE_APPROVAL_MODE,
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.EXPAND_PASTE,
|
||||
Command.TOGGLE_BACKGROUND_SHELL,
|
||||
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||
Command.KILL_BACKGROUND_SHELL,
|
||||
Command.BACKGROUND_SHELL_SELECT,
|
||||
Command.BACKGROUND_SHELL_ESCAPE,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
Command.RESTART_APP,
|
||||
Command.SUSPEND_APP,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for each command, used in docs/tooling.
|
||||
*/
|
||||
export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
// Basic Controls
|
||||
[Command.RETURN]: 'Confirm the current selection or choice.',
|
||||
[Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.',
|
||||
[Command.QUIT]:
|
||||
'Cancel the current request or quit the CLI when input is empty.',
|
||||
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
|
||||
|
||||
// Cursor Movement
|
||||
[Command.HOME]: 'Move the cursor to the start of the line.',
|
||||
[Command.END]: 'Move the cursor to the end of the line.',
|
||||
[Command.MOVE_UP]: 'Move the cursor up one line.',
|
||||
[Command.MOVE_DOWN]: 'Move the cursor down one line.',
|
||||
[Command.MOVE_LEFT]: 'Move the cursor one character to the left.',
|
||||
[Command.MOVE_RIGHT]: 'Move the cursor one character to the right.',
|
||||
[Command.MOVE_WORD_LEFT]: 'Move the cursor one word to the left.',
|
||||
[Command.MOVE_WORD_RIGHT]: 'Move the cursor one word to the right.',
|
||||
|
||||
// Editing
|
||||
[Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.',
|
||||
[Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.',
|
||||
[Command.CLEAR_INPUT]: 'Clear all text in the input field.',
|
||||
[Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.',
|
||||
[Command.DELETE_WORD_FORWARD]: 'Delete the next word.',
|
||||
[Command.DELETE_CHAR_LEFT]: 'Delete the character to the left.',
|
||||
[Command.DELETE_CHAR_RIGHT]: 'Delete the character to the right.',
|
||||
[Command.UNDO]: 'Undo the most recent text edit.',
|
||||
[Command.REDO]: 'Redo the most recent undone text edit.',
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: 'Scroll content up.',
|
||||
[Command.SCROLL_DOWN]: 'Scroll content down.',
|
||||
[Command.SCROLL_HOME]: 'Scroll to the top.',
|
||||
[Command.SCROLL_END]: 'Scroll to the bottom.',
|
||||
[Command.PAGE_UP]: 'Scroll up by one page.',
|
||||
[Command.PAGE_DOWN]: 'Scroll down by one page.',
|
||||
|
||||
// History & Search
|
||||
[Command.HISTORY_UP]: 'Show the previous entry in history.',
|
||||
[Command.HISTORY_DOWN]: 'Show the next entry in history.',
|
||||
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||
'Accept a suggestion while reverse searching.',
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
|
||||
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
|
||||
[Command.DIALOG_NEXT]: 'Move to the next item or question in a dialog.',
|
||||
[Command.DIALOG_PREV]: 'Move to the previous item or question in a dialog.',
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
|
||||
[Command.COMPLETION_UP]: 'Move to the previous completion option.',
|
||||
[Command.COMPLETION_DOWN]: 'Move to the next completion option.',
|
||||
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
|
||||
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
|
||||
|
||||
// Text Input
|
||||
[Command.SUBMIT]: 'Submit the current prompt.',
|
||||
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
||||
[Command.OPEN_EXTERNAL_EDITOR]:
|
||||
'Open the current prompt or the plan in an external editor.',
|
||||
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
|
||||
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
|
||||
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||
[Command.CYCLE_APPROVAL_MODE]:
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand and collapse blocks of content when not in alternate buffer mode.',
|
||||
[Command.EXPAND_PASTE]:
|
||||
'Expand or collapse a paste placeholder when cursor is over placeholder.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]:
|
||||
'Confirm selection in background shell list.',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]:
|
||||
'Toggle current background shell visibility.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]:
|
||||
'Move focus from background shell to Gemini.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
|
||||
'Move focus from background shell list to Gemini.',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to move focus away from background shell.',
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to move focus away from shell input.',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
[Command.RESTART_APP]: 'Restart the application.',
|
||||
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
|
||||
};
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
defaultKeyMatchers,
|
||||
Command,
|
||||
createKeyMatchers,
|
||||
} from './keyMatchers.js';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import { defaultKeyBindings, KeyBinding } from './keyBindings.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
describe('keyMatchers', () => {
|
||||
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
|
||||
name,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: name,
|
||||
...mods,
|
||||
});
|
||||
|
||||
// Test data for each command with positive and negative test cases
|
||||
const testCases = [
|
||||
// Basic bindings
|
||||
{
|
||||
command: Command.RETURN,
|
||||
positive: [createKey('return')],
|
||||
negative: [createKey('r')],
|
||||
},
|
||||
{
|
||||
command: Command.ESCAPE,
|
||||
positive: [createKey('escape')],
|
||||
negative: [
|
||||
createKey('e'),
|
||||
createKey('esc'),
|
||||
createKey('escape', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Cursor movement
|
||||
{
|
||||
command: Command.HOME,
|
||||
positive: [createKey('a', { ctrl: true }), createKey('home')],
|
||||
negative: [
|
||||
createKey('a'),
|
||||
createKey('a', { shift: true }),
|
||||
createKey('b', { ctrl: true }),
|
||||
createKey('home', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.END,
|
||||
positive: [createKey('e', { ctrl: true }), createKey('end')],
|
||||
negative: [
|
||||
createKey('e'),
|
||||
createKey('e', { shift: true }),
|
||||
createKey('a', { ctrl: true }),
|
||||
createKey('end', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.MOVE_LEFT,
|
||||
positive: [createKey('left')],
|
||||
negative: [
|
||||
createKey('left', { ctrl: true }),
|
||||
createKey('b'),
|
||||
createKey('b', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.MOVE_RIGHT,
|
||||
positive: [createKey('right'), createKey('f', { ctrl: true })],
|
||||
negative: [createKey('right', { ctrl: true }), createKey('f')],
|
||||
},
|
||||
{
|
||||
command: Command.MOVE_WORD_LEFT,
|
||||
positive: [
|
||||
createKey('left', { ctrl: true }),
|
||||
createKey('left', { alt: true }),
|
||||
createKey('b', { alt: true }),
|
||||
],
|
||||
negative: [createKey('left'), createKey('b', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.MOVE_WORD_RIGHT,
|
||||
positive: [
|
||||
createKey('right', { ctrl: true }),
|
||||
createKey('right', { alt: true }),
|
||||
createKey('f', { alt: true }),
|
||||
],
|
||||
negative: [createKey('right'), createKey('f', { 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 })],
|
||||
},
|
||||
{
|
||||
command: Command.DELETE_CHAR_LEFT,
|
||||
positive: [createKey('backspace'), createKey('h', { ctrl: true })],
|
||||
negative: [createKey('h'), createKey('x', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.DELETE_CHAR_RIGHT,
|
||||
positive: [createKey('delete'), createKey('d', { ctrl: true })],
|
||||
negative: [createKey('d'), createKey('x', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.DELETE_WORD_BACKWARD,
|
||||
positive: [
|
||||
createKey('backspace', { ctrl: true }),
|
||||
createKey('backspace', { alt: true }),
|
||||
createKey('w', { ctrl: true }),
|
||||
],
|
||||
negative: [createKey('backspace'), createKey('delete', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.DELETE_WORD_FORWARD,
|
||||
positive: [
|
||||
createKey('delete', { ctrl: true }),
|
||||
createKey('delete', { alt: true }),
|
||||
createKey('d', { alt: true }),
|
||||
],
|
||||
negative: [createKey('delete'), createKey('backspace', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.UNDO,
|
||||
positive: [
|
||||
createKey('z', { shift: false, cmd: true }),
|
||||
createKey('z', { shift: false, alt: true }),
|
||||
],
|
||||
negative: [
|
||||
createKey('z'),
|
||||
createKey('z', { shift: true, cmd: true }),
|
||||
createKey('z', { shift: false, ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.REDO,
|
||||
positive: [
|
||||
createKey('z', { shift: true, cmd: true }),
|
||||
createKey('z', { shift: true, alt: true }),
|
||||
createKey('z', { shift: true, ctrl: true }),
|
||||
],
|
||||
negative: [createKey('z'), createKey('z', { shift: false, cmd: true })],
|
||||
},
|
||||
|
||||
// Screen control
|
||||
{
|
||||
command: Command.CLEAR_SCREEN,
|
||||
positive: [createKey('l', { ctrl: true })],
|
||||
negative: [createKey('l'), createKey('k', { ctrl: true })],
|
||||
},
|
||||
|
||||
// Scrolling
|
||||
{
|
||||
command: Command.SCROLL_UP,
|
||||
positive: [createKey('up', { shift: true })],
|
||||
negative: [createKey('up')],
|
||||
},
|
||||
{
|
||||
command: Command.SCROLL_DOWN,
|
||||
positive: [createKey('down', { shift: true })],
|
||||
negative: [createKey('down')],
|
||||
},
|
||||
{
|
||||
command: Command.SCROLL_HOME,
|
||||
positive: [
|
||||
createKey('home', { ctrl: true }),
|
||||
createKey('home', { shift: true }),
|
||||
],
|
||||
negative: [createKey('end'), createKey('home')],
|
||||
},
|
||||
{
|
||||
command: Command.SCROLL_END,
|
||||
positive: [
|
||||
createKey('end', { ctrl: true }),
|
||||
createKey('end', { shift: true }),
|
||||
],
|
||||
negative: [createKey('home'), createKey('end')],
|
||||
},
|
||||
{
|
||||
command: Command.PAGE_UP,
|
||||
positive: [createKey('pageup')],
|
||||
negative: [
|
||||
createKey('pagedown'),
|
||||
createKey('up'),
|
||||
createKey('pageup', { shift: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.PAGE_DOWN,
|
||||
positive: [createKey('pagedown')],
|
||||
negative: [
|
||||
createKey('pageup'),
|
||||
createKey('down'),
|
||||
createKey('pagedown', { 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')],
|
||||
negative: [
|
||||
createKey('p'),
|
||||
createKey('u'),
|
||||
createKey('up', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.NAVIGATION_DOWN,
|
||||
positive: [createKey('down')],
|
||||
negative: [
|
||||
createKey('n'),
|
||||
createKey('d'),
|
||||
createKey('down', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Dialog navigation
|
||||
{
|
||||
command: Command.DIALOG_NAVIGATION_UP,
|
||||
positive: [createKey('up'), createKey('k')],
|
||||
negative: [
|
||||
createKey('up', { shift: true }),
|
||||
createKey('k', { shift: true }),
|
||||
createKey('p'),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.DIALOG_NAVIGATION_DOWN,
|
||||
positive: [createKey('down'), createKey('j')],
|
||||
negative: [
|
||||
createKey('down', { shift: true }),
|
||||
createKey('j', { shift: true }),
|
||||
createKey('n'),
|
||||
],
|
||||
},
|
||||
|
||||
// Auto-completion
|
||||
{
|
||||
command: Command.ACCEPT_SUGGESTION,
|
||||
positive: [createKey('tab'), createKey('return')],
|
||||
negative: [createKey('return', { ctrl: true }), createKey('space')],
|
||||
},
|
||||
{
|
||||
command: Command.COMPLETION_UP,
|
||||
positive: [createKey('up'), createKey('p', { ctrl: true })],
|
||||
negative: [createKey('p'), createKey('down')],
|
||||
},
|
||||
{
|
||||
command: Command.COMPLETION_DOWN,
|
||||
positive: [createKey('down'), createKey('n', { ctrl: true })],
|
||||
negative: [createKey('n'), createKey('up')],
|
||||
},
|
||||
|
||||
// Text input
|
||||
{
|
||||
command: Command.SUBMIT,
|
||||
positive: [createKey('return')],
|
||||
negative: [
|
||||
createKey('return', { ctrl: true }),
|
||||
createKey('return', { cmd: true }),
|
||||
createKey('return', { alt: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.NEWLINE,
|
||||
positive: [
|
||||
createKey('return', { ctrl: true }),
|
||||
createKey('return', { cmd: true }),
|
||||
createKey('return', { alt: true }),
|
||||
],
|
||||
negative: [createKey('return'), createKey('n')],
|
||||
},
|
||||
|
||||
// External tools
|
||||
{
|
||||
command: Command.OPEN_EXTERNAL_EDITOR,
|
||||
positive: [createKey('x', { ctrl: true })],
|
||||
negative: [createKey('x'), createKey('c', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.PASTE_CLIPBOARD,
|
||||
positive: [createKey('v', { ctrl: true })],
|
||||
negative: [createKey('v'), createKey('c', { ctrl: true })],
|
||||
},
|
||||
|
||||
// App level bindings
|
||||
{
|
||||
command: Command.SHOW_ERROR_DETAILS,
|
||||
positive: [createKey('f12')],
|
||||
negative: [
|
||||
createKey('o', { ctrl: true }),
|
||||
createKey('b', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.SHOW_FULL_TODOS,
|
||||
positive: [createKey('t', { ctrl: true })],
|
||||
negative: [createKey('t'), createKey('e', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.SHOW_IDE_CONTEXT_DETAIL,
|
||||
positive: [createKey('g', { ctrl: true })],
|
||||
negative: [createKey('g'), createKey('t', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_MARKDOWN,
|
||||
positive: [createKey('m', { alt: true })],
|
||||
negative: [createKey('m'), createKey('m', { shift: true })],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_COPY_MODE,
|
||||
positive: [createKey('s', { ctrl: true })],
|
||||
negative: [createKey('s'), createKey('s', { alt: true })],
|
||||
},
|
||||
{
|
||||
command: Command.QUIT,
|
||||
positive: [createKey('c', { ctrl: true })],
|
||||
negative: [createKey('c'), createKey('d', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.EXIT,
|
||||
positive: [createKey('d', { ctrl: true })],
|
||||
negative: [createKey('d'), createKey('c', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.SUSPEND_APP,
|
||||
positive: [createKey('z', { ctrl: true })],
|
||||
negative: [
|
||||
createKey('z'),
|
||||
createKey('y', { ctrl: true }),
|
||||
createKey('z', { alt: true }),
|
||||
createKey('z', { ctrl: true, shift: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.SHOW_MORE_LINES,
|
||||
positive: [createKey('o', { ctrl: true })],
|
||||
negative: [
|
||||
createKey('s', { ctrl: true }),
|
||||
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')],
|
||||
negative: [
|
||||
createKey('return'),
|
||||
createKey('space'),
|
||||
createKey('tab', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.FOCUS_SHELL_INPUT,
|
||||
positive: [createKey('tab')],
|
||||
negative: [createKey('f6'), createKey('f', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_YOLO,
|
||||
positive: [createKey('y', { ctrl: true })],
|
||||
negative: [createKey('y'), createKey('y', { alt: true })],
|
||||
},
|
||||
{
|
||||
command: Command.CYCLE_APPROVAL_MODE,
|
||||
positive: [createKey('tab', { shift: true })],
|
||||
negative: [createKey('tab')],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_BACKGROUND_SHELL,
|
||||
positive: [createKey('b', { ctrl: true })],
|
||||
negative: [createKey('f10'), createKey('b')],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||
positive: [createKey('l', { ctrl: true })],
|
||||
negative: [createKey('l')],
|
||||
},
|
||||
];
|
||||
|
||||
describe('Data-driven key binding matches original logic', () => {
|
||||
testCases.forEach(({ command, positive, negative }) => {
|
||||
it(`should match ${command} correctly`, () => {
|
||||
positive.forEach((key) => {
|
||||
expect(
|
||||
defaultKeyMatchers[command](key),
|
||||
`Expected ${command} to match ${JSON.stringify(key)}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
negative.forEach((key) => {
|
||||
expect(
|
||||
defaultKeyMatchers[command](key),
|
||||
`Expected ${command} to NOT match ${JSON.stringify(key)}`,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom key bindings', () => {
|
||||
it('should work with custom configuration', () => {
|
||||
const customConfig: KeyBindingConfig = {
|
||||
...defaultKeyBindings,
|
||||
[Command.HOME]: [new KeyBinding('ctrl+h'), new KeyBinding('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]: [new KeyBinding('ctrl+q'), new KeyBinding('alt+q')],
|
||||
};
|
||||
|
||||
const matchers = createKeyMatchers(config);
|
||||
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
|
||||
expect(matchers[Command.QUIT](createKey('q', { alt: 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import { Command, defaultKeyBindings } from './keyBindings.js';
|
||||
|
||||
/**
|
||||
* Checks if a key matches any of the bindings for a command
|
||||
*/
|
||||
function matchCommand(
|
||||
command: Command,
|
||||
key: Key,
|
||||
config: KeyBindingConfig = defaultKeyBindings,
|
||||
): boolean {
|
||||
return config[command].some((binding) => binding.matches(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 {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
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 defaultKeyMatchers: KeyMatchers =
|
||||
createKeyMatchers(defaultKeyBindings);
|
||||
|
||||
// Re-export Command for convenience
|
||||
export { Command };
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Key } from '../contexts/KeypressContext.js';
|
||||
|
||||
export type { Key };
|
||||
|
||||
const SPECIAL_KEYS: Record<string, string> = {
|
||||
up: '\x1b[A',
|
||||
down: '\x1b[B',
|
||||
right: '\x1b[C',
|
||||
left: '\x1b[D',
|
||||
escape: '\x1b',
|
||||
tab: '\t',
|
||||
backspace: '\x7f',
|
||||
delete: '\x1b[3~',
|
||||
home: '\x1b[H',
|
||||
end: '\x1b[F',
|
||||
pageup: '\x1b[5~',
|
||||
pagedown: '\x1b[6~',
|
||||
return: '\r',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates a Key object into its corresponding ANSI escape sequence.
|
||||
* This is useful for sending control characters to a pseudo-terminal.
|
||||
*
|
||||
* @param key The Key object to translate.
|
||||
* @returns The ANSI escape sequence as a string, or null if no mapping exists.
|
||||
*/
|
||||
export function keyToAnsi(key: Key): string | null {
|
||||
if (key.ctrl) {
|
||||
// Ctrl + letter (A-Z maps to 1-26, e.g., Ctrl+C is \x03)
|
||||
if (key.name >= 'a' && key.name <= 'z') {
|
||||
return String.fromCharCode(
|
||||
key.name.charCodeAt(0) - 'a'.charCodeAt(0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow keys and other special keys
|
||||
if (key.name in SPECIAL_KEYS) {
|
||||
return SPECIAL_KEYS[key.name];
|
||||
}
|
||||
|
||||
// If it's a simple character, return it.
|
||||
if (!key.ctrl && !key.cmd && key.sequence) {
|
||||
return key.sequence;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
|
||||
import { Command, KeyBinding } from './keyBindings.js';
|
||||
|
||||
describe('keybindingUtils', () => {
|
||||
describe('formatKeyBinding', () => {
|
||||
const testCases: Array<{
|
||||
name: string;
|
||||
binding: KeyBinding;
|
||||
expected: {
|
||||
darwin: string;
|
||||
win32: string;
|
||||
linux: string;
|
||||
default: string;
|
||||
};
|
||||
}> = [
|
||||
{
|
||||
name: 'simple key',
|
||||
binding: new KeyBinding('a'),
|
||||
expected: { darwin: 'A', win32: 'A', linux: 'A', default: 'A' },
|
||||
},
|
||||
{
|
||||
name: 'named key (return)',
|
||||
binding: new KeyBinding('return'),
|
||||
expected: {
|
||||
darwin: 'Enter',
|
||||
win32: 'Enter',
|
||||
linux: 'Enter',
|
||||
default: 'Enter',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'named key (escape)',
|
||||
binding: new KeyBinding('escape'),
|
||||
expected: { darwin: 'Esc', win32: 'Esc', linux: 'Esc', default: 'Esc' },
|
||||
},
|
||||
{
|
||||
name: 'ctrl modifier',
|
||||
binding: new KeyBinding('ctrl+c'),
|
||||
expected: {
|
||||
darwin: 'Ctrl+C',
|
||||
win32: 'Ctrl+C',
|
||||
linux: 'Ctrl+C',
|
||||
default: 'Ctrl+C',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cmd modifier',
|
||||
binding: new KeyBinding('cmd+z'),
|
||||
expected: {
|
||||
darwin: 'Cmd+Z',
|
||||
win32: 'Win+Z',
|
||||
linux: 'Super+Z',
|
||||
default: 'Cmd/Win+Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'alt/option modifier',
|
||||
binding: new KeyBinding('alt+left'),
|
||||
expected: {
|
||||
darwin: 'Option+Left',
|
||||
win32: 'Alt+Left',
|
||||
linux: 'Alt+Left',
|
||||
default: 'Alt+Left',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'shift modifier',
|
||||
binding: new KeyBinding('shift+up'),
|
||||
expected: {
|
||||
darwin: 'Shift+Up',
|
||||
win32: 'Shift+Up',
|
||||
linux: 'Shift+Up',
|
||||
default: 'Shift+Up',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'multiple modifiers (ctrl+shift)',
|
||||
binding: new KeyBinding('ctrl+shift+z'),
|
||||
expected: {
|
||||
darwin: 'Ctrl+Shift+Z',
|
||||
win32: 'Ctrl+Shift+Z',
|
||||
linux: 'Ctrl+Shift+Z',
|
||||
default: 'Ctrl+Shift+Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'all modifiers',
|
||||
binding: new KeyBinding('ctrl+alt+shift+cmd+a'),
|
||||
expected: {
|
||||
darwin: 'Ctrl+Option+Shift+Cmd+A',
|
||||
win32: 'Ctrl+Alt+Shift+Win+A',
|
||||
linux: 'Ctrl+Alt+Shift+Super+A',
|
||||
default: 'Ctrl+Alt+Shift+Cmd/Win+A',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, binding, expected }) => {
|
||||
describe(`${name}`, () => {
|
||||
it('formats correctly for darwin', () => {
|
||||
expect(formatKeyBinding(binding, 'darwin')).toBe(expected.darwin);
|
||||
});
|
||||
it('formats correctly for win32', () => {
|
||||
expect(formatKeyBinding(binding, 'win32')).toBe(expected.win32);
|
||||
});
|
||||
it('formats correctly for linux', () => {
|
||||
expect(formatKeyBinding(binding, 'linux')).toBe(expected.linux);
|
||||
});
|
||||
it('formats correctly for default', () => {
|
||||
expect(formatKeyBinding(binding, 'default')).toBe(expected.default);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCommand', () => {
|
||||
it('formats default commands (using default platform behavior)', () => {
|
||||
expect(formatCommand(Command.QUIT, undefined, 'default')).toBe('Ctrl+C');
|
||||
expect(formatCommand(Command.SUBMIT, undefined, 'default')).toBe('Enter');
|
||||
expect(
|
||||
formatCommand(Command.TOGGLE_BACKGROUND_SHELL, undefined, 'default'),
|
||||
).toBe('Ctrl+B');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown commands', () => {
|
||||
expect(
|
||||
formatCommand(
|
||||
'unknown.command' as unknown as Command,
|
||||
undefined,
|
||||
'default',
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import {
|
||||
type Command,
|
||||
type KeyBinding,
|
||||
type KeyBindingConfig,
|
||||
defaultKeyBindings,
|
||||
} from './keyBindings.js';
|
||||
|
||||
/**
|
||||
* Maps internal key names to user-friendly display names.
|
||||
*/
|
||||
const KEY_NAME_MAP: Record<string, string> = {
|
||||
return: 'Enter',
|
||||
escape: 'Esc',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
left: 'Left',
|
||||
right: 'Right',
|
||||
pageup: 'Page Up',
|
||||
pagedown: 'Page Down',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
tab: 'Tab',
|
||||
space: 'Space',
|
||||
};
|
||||
|
||||
interface ModifierMap {
|
||||
ctrl: string;
|
||||
alt: string;
|
||||
shift: string;
|
||||
cmd: string;
|
||||
}
|
||||
|
||||
const MODIFIER_MAPS: Record<string, ModifierMap> = {
|
||||
darwin: {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Option',
|
||||
shift: 'Shift',
|
||||
cmd: 'Cmd',
|
||||
},
|
||||
win32: {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
cmd: 'Win',
|
||||
},
|
||||
linux: {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
cmd: 'Super',
|
||||
},
|
||||
default: {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
cmd: 'Cmd/Win',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C").
|
||||
*/
|
||||
export function formatKeyBinding(
|
||||
binding: KeyBinding,
|
||||
platform?: string,
|
||||
): string {
|
||||
const activePlatform =
|
||||
platform ??
|
||||
(process.env['FORCE_GENERIC_KEYBINDING_HINTS']
|
||||
? 'default'
|
||||
: process.platform);
|
||||
const modMap = MODIFIER_MAPS[activePlatform] || MODIFIER_MAPS['default'];
|
||||
const parts: string[] = [];
|
||||
|
||||
if (binding.ctrl) parts.push(modMap.ctrl);
|
||||
if (binding.alt) parts.push(modMap.alt);
|
||||
if (binding.shift) parts.push(modMap.shift);
|
||||
if (binding.cmd) parts.push(modMap.cmd);
|
||||
|
||||
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
|
||||
parts.push(keyName);
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the primary keybinding for a command.
|
||||
*/
|
||||
export function formatCommand(
|
||||
command: Command,
|
||||
config: KeyBindingConfig = defaultKeyBindings,
|
||||
platform?: string,
|
||||
): string {
|
||||
const bindings = config[command];
|
||||
if (!bindings || bindings.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Use the first binding as the primary one for display
|
||||
return formatKeyBinding(bindings[0], platform);
|
||||
}
|
||||
Reference in New Issue
Block a user