mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
feat(cli): customizable keyboard shortcuts (#21945)
This commit is contained in:
committed by
GitHub
parent
657f19c1f3
commit
daf3701194
@@ -4,6 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import { parse as parseIgnoringComments } from 'comment-json';
|
||||
import { isNodeError, Storage } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Command enum for all available keyboard shortcuts
|
||||
*/
|
||||
@@ -213,153 +218,172 @@ export class KeyBinding {
|
||||
/**
|
||||
* Configuration type mapping commands to their key bindings
|
||||
*/
|
||||
export type KeyBindingConfig = {
|
||||
readonly [C in Command]: readonly KeyBinding[];
|
||||
};
|
||||
export type KeyBindingConfig = Map<Command, readonly KeyBinding[]>;
|
||||
|
||||
/**
|
||||
* Default key binding configuration
|
||||
* Matches the original hard-coded logic exactly
|
||||
*/
|
||||
export const defaultKeyBindings: KeyBindingConfig = {
|
||||
export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
// Basic Controls
|
||||
[Command.RETURN]: [new KeyBinding('enter')],
|
||||
[Command.ESCAPE]: [new KeyBinding('escape'), new KeyBinding('ctrl+[')],
|
||||
[Command.QUIT]: [new KeyBinding('ctrl+c')],
|
||||
[Command.EXIT]: [new KeyBinding('ctrl+d')],
|
||||
[Command.RETURN, [new KeyBinding('enter')]],
|
||||
[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.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'),
|
||||
[
|
||||
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.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_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_LEFT,
|
||||
[new KeyBinding('backspace'), new KeyBinding('ctrl+h')],
|
||||
],
|
||||
[Command.DELETE_CHAR_RIGHT]: [
|
||||
new KeyBinding('delete'),
|
||||
new KeyBinding('ctrl+d'),
|
||||
[
|
||||
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'),
|
||||
[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_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.SCROLL_END,
|
||||
[new KeyBinding('ctrl+end'), new KeyBinding('shift+end')],
|
||||
],
|
||||
[Command.PAGE_UP]: [new KeyBinding('pageup')],
|
||||
[Command.PAGE_DOWN]: [new KeyBinding('pagedown')],
|
||||
[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('enter')],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [new KeyBinding('tab')],
|
||||
[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('enter')]],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, [new KeyBinding('tab')]],
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: [new KeyBinding('up')],
|
||||
[Command.NAVIGATION_DOWN]: [new KeyBinding('down')],
|
||||
[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_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')],
|
||||
[Command.DIALOG_NEXT, [new KeyBinding('tab')]],
|
||||
[Command.DIALOG_PREV, [new KeyBinding('shift+tab')]],
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [new KeyBinding('tab'), new KeyBinding('enter')],
|
||||
[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')],
|
||||
[Command.ACCEPT_SUGGESTION, [new KeyBinding('tab'), new KeyBinding('enter')]],
|
||||
[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('enter')],
|
||||
[Command.NEWLINE]: [
|
||||
new KeyBinding('ctrl+enter'),
|
||||
new KeyBinding('cmd+enter'),
|
||||
new KeyBinding('alt+enter'),
|
||||
new KeyBinding('shift+enter'),
|
||||
new KeyBinding('ctrl+j'),
|
||||
[Command.SUBMIT, [new KeyBinding('enter')]],
|
||||
[
|
||||
Command.NEWLINE,
|
||||
[
|
||||
new KeyBinding('ctrl+enter'),
|
||||
new KeyBinding('cmd+enter'),
|
||||
new KeyBinding('alt+enter'),
|
||||
new KeyBinding('shift+enter'),
|
||||
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'),
|
||||
[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.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')],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')],
|
||||
[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.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')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('enter')],
|
||||
[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.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
[Command.BACKGROUND_SHELL_SELECT, [new KeyBinding('enter')]],
|
||||
[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')]],
|
||||
]);
|
||||
|
||||
interface CommandCategory {
|
||||
readonly title: string;
|
||||
@@ -593,3 +617,62 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to move focus away from background shell.',
|
||||
};
|
||||
|
||||
const keybindingsSchema = z.array(
|
||||
z.object({
|
||||
command: z.nativeEnum(Command),
|
||||
key: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads custom keybindings from the user's keybindings.json file.
|
||||
* Keybindings are merged with the default bindings.
|
||||
*/
|
||||
export async function loadCustomKeybindings(): Promise<{
|
||||
config: KeyBindingConfig;
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let config = defaultKeyBindingConfig;
|
||||
|
||||
const userKeybindingsPath = Storage.getUserKeybindingsPath();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(userKeybindingsPath, 'utf8');
|
||||
const parsedJson = parseIgnoringComments(content);
|
||||
const result = keybindingsSchema.safeParse(parsedJson);
|
||||
|
||||
if (result.success) {
|
||||
config = new Map(defaultKeyBindingConfig);
|
||||
for (const { command, key } of result.data) {
|
||||
const currentBindings = config.get(command) ?? [];
|
||||
|
||||
try {
|
||||
const keyBinding = new KeyBinding(key);
|
||||
// Add new binding (prepend so it's the primary one shown in UI)
|
||||
config.set(command, [keyBinding, ...currentBindings]);
|
||||
} catch (e) {
|
||||
errors.push(`Invalid keybinding for command "${command}": ${e}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push(
|
||||
...result.error.issues.map(
|
||||
(issue) =>
|
||||
`Keybindings file "${userKeybindingsPath}" error at ${issue.path.join('.')}: ${issue.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
// File doesn't exist, use default bindings
|
||||
} else {
|
||||
errors.push(
|
||||
`Error reading keybindings file "${userKeybindingsPath}": ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { config, errors };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user