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) ?? [], })), })); }