diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index b1898ba8ef..4e95629908 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -92,6 +92,8 @@ import { computeTerminalTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
+import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
+import { loadKeyMatchers } from './ui/key/keyMatchers.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import {
@@ -208,6 +210,11 @@ export async function startInteractiveUI(
});
}
+ const { matchers, errors } = await loadKeyMatchers();
+ errors.forEach((error) => {
+ coreEvents.emitFeedback('warning', error);
+ });
+
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
@@ -230,35 +237,39 @@ export async function startInteractiveUI(
return (
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/packages/cli/src/ui/hooks/useKeyMatchers.ts b/packages/cli/src/ui/hooks/useKeyMatchers.ts
deleted file mode 100644
index b14ab67eda..0000000000
--- a/packages/cli/src/ui/hooks/useKeyMatchers.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * @license
- * Copyright 2026 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { useMemo } from 'react';
-import type { KeyMatchers } from '../key/keyMatchers.js';
-import { defaultKeyMatchers } from '../key/keyMatchers.js';
-
-/**
- * Hook to retrieve the currently active key matchers.
- * This prepares the codebase for dynamic or custom key bindings in the future.
- */
-export function useKeyMatchers(): KeyMatchers {
- return useMemo(() => defaultKeyMatchers, []);
-}
diff --git a/packages/cli/src/ui/hooks/useKeyMatchers.tsx b/packages/cli/src/ui/hooks/useKeyMatchers.tsx
new file mode 100644
index 0000000000..c2ca225c1e
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useKeyMatchers.tsx
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { createContext, useContext } from 'react';
+import type { KeyMatchers } from '../key/keyMatchers.js';
+import { defaultKeyMatchers } from '../key/keyMatchers.js';
+
+export const KeyMatchersContext =
+ createContext(defaultKeyMatchers);
+
+export const KeyMatchersProvider = ({
+ children,
+ value,
+}: {
+ children: React.ReactNode;
+ value: KeyMatchers;
+}): React.JSX.Element => (
+
+ {children}
+
+);
+
+/**
+ * Hook to retrieve the currently active key matchers.
+ * Defaults to defaultKeyMatchers if no provider is present, allowing tests to run without explicit wrappers.
+ */
+export function useKeyMatchers(): KeyMatchers {
+ return useContext(KeyMatchersContext);
+}
diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts
index 3057bf85b6..fb342e7513 100644
--- a/packages/cli/src/ui/key/keyBindings.test.ts
+++ b/packages/cli/src/ui/key/keyBindings.test.ts
@@ -4,14 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect } from 'vitest';
-import type { KeyBindingConfig } from './keyBindings.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import * as fs from 'node:fs/promises';
+import { Storage } from '@google/gemini-cli-core';
import {
Command,
commandCategories,
commandDescriptions,
- defaultKeyBindings,
+ defaultKeyBindingConfig,
KeyBinding,
+ loadCustomKeybindings,
} from './keyBindings.js';
describe('KeyBinding', () => {
@@ -104,26 +108,11 @@ describe('KeyBinding', () => {
});
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();
- });
+ it('should have bindings for all commands', () => {
+ for (const command of Object.values(Command)) {
+ expect(defaultKeyBindingConfig.has(command)).toBe(true);
+ expect(defaultKeyBindingConfig.get(command)?.length).toBeGreaterThan(0);
+ }
});
describe('command metadata', () => {
@@ -157,3 +146,92 @@ describe('keyBindings config', () => {
});
});
});
+
+describe('loadCustomKeybindings', () => {
+ let tempDir: string;
+ let tempFilePath: string;
+
+ beforeEach(async () => {
+ tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'gemini-keybindings-test-'),
+ );
+ tempFilePath = path.join(tempDir, 'keybindings.json');
+ vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
+ });
+
+ afterEach(async () => {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ vi.restoreAllMocks();
+ });
+
+ it('returns default bindings when file does not exist', async () => {
+ // We don't write the file.
+ const { config, errors } = await loadCustomKeybindings();
+
+ expect(errors).toHaveLength(0);
+ expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
+ });
+
+ it('merges valid custom bindings, prepending them to defaults', async () => {
+ const customJson = JSON.stringify([
+ { command: Command.RETURN, key: 'ctrl+a' },
+ ]);
+ await fs.writeFile(tempFilePath, customJson, 'utf8');
+
+ const { config, errors } = await loadCustomKeybindings();
+
+ expect(errors).toHaveLength(0);
+ expect(config.get(Command.RETURN)).toEqual([
+ new KeyBinding('ctrl+a'),
+ new KeyBinding('enter'),
+ ]);
+ });
+
+ it('handles JSON with comments', async () => {
+ const customJson = `
+ [
+ // This is a comment
+ { "command": "${Command.QUIT}", "key": "ctrl+x" }
+ ]
+ `;
+ await fs.writeFile(tempFilePath, customJson, 'utf8');
+
+ const { config, errors } = await loadCustomKeybindings();
+
+ expect(errors).toHaveLength(0);
+ expect(config.get(Command.QUIT)).toEqual([
+ new KeyBinding('ctrl+x'),
+ new KeyBinding('ctrl+c'),
+ ]);
+ });
+
+ it('returns validation errors for invalid schema', async () => {
+ const invalidJson = JSON.stringify([{ command: 'unknown', key: 'a' }]);
+ await fs.writeFile(tempFilePath, invalidJson, 'utf8');
+
+ const { config, errors } = await loadCustomKeybindings();
+
+ expect(errors.length).toBeGreaterThan(0);
+
+ expect(errors[0]).toMatch(/error at 0.command: Invalid enum value/);
+ // Should still have defaults
+ expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
+ });
+
+ it('returns validation errors for invalid key patterns but loads valid ones', async () => {
+ const mixedJson = JSON.stringify([
+ { command: Command.RETURN, key: 'super+a' }, // invalid
+ { command: Command.QUIT, key: 'ctrl+y' }, // valid
+ ]);
+ await fs.writeFile(tempFilePath, mixedJson, 'utf8');
+
+ const { config, errors } = await loadCustomKeybindings();
+
+ expect(errors.length).toBe(1);
+ expect(errors[0]).toMatch(/Invalid keybinding/);
+ expect(config.get(Command.QUIT)).toEqual([
+ new KeyBinding('ctrl+y'),
+ new KeyBinding('ctrl+c'),
+ ]);
+ });
+});
diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts
index 5f1e833a53..fcf38d476a 100644
--- a/packages/cli/src/ui/key/keyBindings.ts
+++ b/packages/cli/src/ui/key/keyBindings.ts
@@ -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;
/**
* 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> = {
[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 };
+}
diff --git a/packages/cli/src/ui/key/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts
index 12e2f07bc2..b1d7ddc304 100644
--- a/packages/cli/src/ui/key/keyMatchers.test.ts
+++ b/packages/cli/src/ui/key/keyMatchers.test.ts
@@ -4,28 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import * as fs from 'node:fs/promises';
+import { Storage } from '@google/gemini-cli-core';
import {
defaultKeyMatchers,
Command,
createKeyMatchers,
+ loadKeyMatchers,
} from './keyMatchers.js';
-import type { KeyBindingConfig } from './keyBindings.js';
-import { defaultKeyBindings, KeyBinding } from './keyBindings.js';
+import { defaultKeyBindingConfig, KeyBinding } from './keyBindings.js';
import type { Key } from '../hooks/useKeypress.js';
-describe('keyMatchers', () => {
- const createKey = (name: string, mods: Partial = {}): Key => ({
- name,
- shift: false,
- alt: false,
- ctrl: false,
- cmd: false,
- insertable: false,
- sequence: name,
- ...mods,
- });
+const createKey = (name: string, mods: Partial = {}): Key => ({
+ name,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ insertable: false,
+ sequence: name,
+ ...mods,
+});
+describe('keyMatchers', () => {
// Test data for each command with positive and negative test cases
const testCases = [
// Basic bindings
@@ -443,10 +447,11 @@ describe('keyMatchers', () => {
describe('Custom key bindings', () => {
it('should work with custom configuration', () => {
- const customConfig: KeyBindingConfig = {
- ...defaultKeyBindings,
- [Command.HOME]: [new KeyBinding('ctrl+h'), new KeyBinding('0')],
- };
+ const customConfig = new Map(defaultKeyBindingConfig);
+ customConfig.set(Command.HOME, [
+ new KeyBinding('ctrl+h'),
+ new KeyBinding('0'),
+ ]);
const customMatchers = createKeyMatchers(customConfig);
@@ -460,10 +465,11 @@ describe('keyMatchers', () => {
});
it('should support multiple key bindings for same command', () => {
- const config: KeyBindingConfig = {
- ...defaultKeyBindings,
- [Command.QUIT]: [new KeyBinding('ctrl+q'), new KeyBinding('alt+q')],
- };
+ const config = new Map(defaultKeyBindingConfig);
+ config.set(Command.QUIT, [
+ new KeyBinding('ctrl+q'),
+ new KeyBinding('alt+q'),
+ ]);
const matchers = createKeyMatchers(config);
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
@@ -473,10 +479,8 @@ describe('keyMatchers', () => {
describe('Edge Cases', () => {
it('should handle empty binding arrays', () => {
- const config: KeyBindingConfig = {
- ...defaultKeyBindings,
- [Command.HOME]: [],
- };
+ const config = new Map(defaultKeyBindingConfig);
+ config.set(Command.HOME, []);
const matchers = createKeyMatchers(config);
expect(matchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
@@ -485,3 +489,44 @@ describe('keyMatchers', () => {
});
});
});
+
+describe('loadKeyMatchers integration', () => {
+ let tempDir: string;
+ let tempFilePath: string;
+
+ beforeEach(async () => {
+ tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'gemini-keymatchers-test-'),
+ );
+ tempFilePath = path.join(tempDir, 'keybindings.json');
+ vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
+ });
+
+ afterEach(async () => {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ vi.restoreAllMocks();
+ });
+
+ it('loads matchers from a real file on disk', async () => {
+ const customJson = JSON.stringify([
+ { command: Command.QUIT, key: 'ctrl+y' },
+ ]);
+ await fs.writeFile(tempFilePath, customJson, 'utf8');
+
+ const { matchers, errors } = await loadKeyMatchers();
+
+ expect(errors).toHaveLength(0);
+ // User binding matches
+ expect(matchers[Command.QUIT](createKey('y', { ctrl: true }))).toBe(true);
+ // Default binding still matches as fallback
+ expect(matchers[Command.QUIT](createKey('c', { ctrl: true }))).toBe(true);
+ });
+
+ it('returns errors when the file on disk is invalid', async () => {
+ await fs.writeFile(tempFilePath, 'invalid json {', 'utf8');
+
+ const { errors } = await loadKeyMatchers();
+
+ expect(errors.length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/cli/src/ui/key/keyMatchers.ts b/packages/cli/src/ui/key/keyMatchers.ts
index a346ecb3ad..46f17239b7 100644
--- a/packages/cli/src/ui/key/keyMatchers.ts
+++ b/packages/cli/src/ui/key/keyMatchers.ts
@@ -6,7 +6,11 @@
import type { Key } from '../hooks/useKeypress.js';
import type { KeyBindingConfig } from './keyBindings.js';
-import { Command, defaultKeyBindings } from './keyBindings.js';
+import {
+ Command,
+ defaultKeyBindingConfig,
+ loadCustomKeybindings,
+} from './keyBindings.js';
/**
* Checks if a key matches any of the bindings for a command
@@ -14,9 +18,11 @@ import { Command, defaultKeyBindings } from './keyBindings.js';
function matchCommand(
command: Command,
key: Key,
- config: KeyBindingConfig = defaultKeyBindings,
+ config: KeyBindingConfig = defaultKeyBindingConfig,
): boolean {
- return config[command].some((binding) => binding.matches(key));
+ const bindings = config.get(command);
+ if (!bindings) return false;
+ return bindings.some((binding) => binding.matches(key));
}
/**
@@ -35,7 +41,7 @@ export type KeyMatchers = {
* Creates key matchers from a key binding configuration
*/
export function createKeyMatchers(
- config: KeyBindingConfig = defaultKeyBindings,
+ config: KeyBindingConfig = defaultKeyBindingConfig,
): KeyMatchers {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const matchers = {} as { [C in Command]: KeyMatcher };
@@ -50,8 +56,23 @@ export function createKeyMatchers(
/**
* Default key binding matchers using the default configuration
*/
-export const defaultKeyMatchers: KeyMatchers =
- createKeyMatchers(defaultKeyBindings);
+export const defaultKeyMatchers: KeyMatchers = createKeyMatchers(
+ defaultKeyBindingConfig,
+);
// Re-export Command for convenience
export { Command };
+
+/**
+ * Loads and creates key matchers including user customizations.
+ */
+export async function loadKeyMatchers(): Promise<{
+ matchers: KeyMatchers;
+ errors: string[];
+}> {
+ const { config, errors } = await loadCustomKeybindings();
+ return {
+ matchers: createKeyMatchers(config),
+ errors,
+ };
+}
diff --git a/packages/cli/src/ui/key/keybindingUtils.ts b/packages/cli/src/ui/key/keybindingUtils.ts
index f0ec6e37bd..0c79e67d13 100644
--- a/packages/cli/src/ui/key/keybindingUtils.ts
+++ b/packages/cli/src/ui/key/keybindingUtils.ts
@@ -9,7 +9,7 @@ import {
type Command,
type KeyBinding,
type KeyBindingConfig,
- defaultKeyBindings,
+ defaultKeyBindingConfig,
} from './keyBindings.js';
/**
@@ -97,10 +97,10 @@ export function formatKeyBinding(
*/
export function formatCommand(
command: Command,
- config: KeyBindingConfig = defaultKeyBindings,
+ config: KeyBindingConfig = defaultKeyBindingConfig,
platform?: string,
): string {
- const bindings = config[command];
+ const bindings = config.get(command);
if (!bindings || bindings.length === 0) {
return '';
}
diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts
index b89c2bccbc..f0e9c0220b 100644
--- a/packages/core/src/config/storage.ts
+++ b/packages/core/src/config/storage.ts
@@ -98,6 +98,10 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), 'policies');
}
+ static getUserKeybindingsPath(): string {
+ return path.join(Storage.getGlobalGeminiDir(), 'keybindings.json');
+ }
+
static getUserAgentsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'agents');
}
diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts
index ab452bb8f2..ee908ff25d 100644
--- a/scripts/generate-keybindings-doc.ts
+++ b/scripts/generate-keybindings-doc.ts
@@ -12,7 +12,7 @@ import type { KeyBinding } from '../packages/cli/src/ui/key/keyBindings.js';
import {
commandCategories,
commandDescriptions,
- defaultKeyBindings,
+ defaultKeyBindingConfig,
} from '../packages/cli/src/ui/key/keyBindings.js';
import {
formatWithPrettier,
@@ -82,7 +82,7 @@ export function buildDefaultDocSections(): readonly KeybindingDocSection[] {
title: category.title,
commands: category.commands.map((command) => ({
description: commandDescriptions[command],
- bindings: defaultKeyBindings[command],
+ bindings: defaultKeyBindingConfig.get(command) ?? [],
})),
}));
}