diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index f8aafa6502..e731b64b2d 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -130,10 +130,9 @@ available combinations. ## Customizing Keybindings -You can add alternative keybindings for commands by creating or modifying the +You can add alternative keybindings or remove default keybindings by creating a `keybindings.json` file in your home gemini directory (typically -`~/.gemini/keybindings.json`). This allows you to bind commands to additional -key combinations. Note that default keybindings cannot be removed. +`~/.gemini/keybindings.json`). ### Configuration Format @@ -144,28 +143,57 @@ a `key` combination. ```json [ { - "command": "input.submit", - "key": "cmd+s" + "command": "edit.clear", + "key": "cmd+l" }, { - "command": "edit.clear", - "key": "ctrl+l" + // prefix "-" to unbind a key + "command": "-app.toggleYolo", + "key": "ctrl+y" + }, + { + "command": "input.submit", + "key": "ctrl+y" + }, + { + // multiple modifiers + "command": "cursor.right", + "key": "shift+alt+a" + }, + { + // Some mac keyboards send "Å" instead of "shift+option+a" + "command": "cursor.right", + "key": "Å" + }, + { + // some base keys have special multi-char names + "command": "cursor.right", + "key": "shift+pageup" } ] ``` -### Keyboard Rules - +- **Unbinding** To remove an existing or default keybinding, prefix a minus sign + (`-`) to the `command` name. +- **No Auto-unbinding** The same key can be bound to multiple commands in + different contexts at the same time. Therefore, creating a binding does not + automatically unbind the key from other commands. - **Explicit Modifiers**: Key matching is explicit. For example, a binding for `ctrl+f` will only trigger on exactly `ctrl+f`, not `ctrl+shift+f` or - `alt+ctrl+f`. You must specify the exact modifier keys (`ctrl`, `shift`, - `alt`/`opt`/`option`, `cmd`/`meta`). + `alt+ctrl+f`. - **Literal Characters**: Terminals often translate complex key combinations - (especially on macOS with the `Option` key) into special characters. To handle - this reliably across all operating systems and SSH sessions, you can bind - directly to the literal character produced. For example, instead of trying to - bind `shift+5`, bind directly to `%`. -- **Special Keys**: Supported special keys include: + (especially on macOS with the `Option` key) into special characters, losing + modifier and keystroke information along the way. For example,`shift+5` might + be sent as `%`. In these cases, you must bind to the literal character `%` as + bindings to `shift+5` will never fire. To see precisely what is being sent, + enable `Debug Keystroke Logging` and hit f12 to open the debug log console. +- **Key Modifiers**: The supported key modifiers are: + - `ctrl` + - `shift`, + - `alt` (synonyms: `opt`, `option`) + - `cmd` (synonym: `meta`) +- **Base Key**: The base key can be any single unicode code point or any of the + following special keys: - **Navigation**: `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown` - **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`, diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts index c8a1c8787e..77237f128f 100644 --- a/packages/cli/src/ui/key/keyBindings.test.ts +++ b/packages/cli/src/ui/key/keyBindings.test.ts @@ -206,7 +206,7 @@ describe('loadCustomKeybindings', () => { expect(errors.length).toBeGreaterThan(0); - expect(errors[0]).toMatch(/error at 0.command: Invalid enum value/); + expect(errors[0]).toMatch(/error at 0.command: Invalid command: "unknown"/); // Should still have defaults expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]); }); @@ -227,4 +227,34 @@ describe('loadCustomKeybindings', () => { new KeyBinding('ctrl+c'), ]); }); + + it('removes specific bindings when using the minus prefix', async () => { + const customJson = JSON.stringify([ + { command: `-${Command.RETURN}`, key: 'enter' }, + { command: Command.RETURN, key: 'ctrl+a' }, + ]); + await fs.writeFile(tempFilePath, customJson, 'utf8'); + + const { config, errors } = await loadCustomKeybindings(); + + expect(errors).toHaveLength(0); + // 'enter' should be gone, only 'ctrl+a' should remain + expect(config.get(Command.RETURN)).toEqual([new KeyBinding('ctrl+a')]); + }); + + it('returns an error when attempting to negate a non-existent binding', async () => { + const customJson = JSON.stringify([ + { command: `-${Command.RETURN}`, key: 'ctrl+z' }, + ]); + await fs.writeFile(tempFilePath, customJson, 'utf8'); + + const { config, errors } = await loadCustomKeybindings(); + + expect(errors.length).toBe(1); + expect(errors[0]).toMatch( + /Invalid keybinding for command "-basic.confirm": Error: cannot remove "ctrl\+z" since it is not bound/, + ); + // Defaults should still be present + expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]); + }); }); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index b151ad8ee3..e8014b7429 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -216,6 +216,16 @@ export class KeyBinding { !!key.cmd === !!this.cmd ); } + + equals(other: KeyBinding): boolean { + return ( + this.key === other.key && + this.shift === other.shift && + this.alt === other.alt && + this.ctrl === other.ctrl && + this.cmd === other.cmd + ); + } } /** @@ -622,10 +632,32 @@ export const commandDescriptions: Readonly> = { }; const keybindingsSchema = z.array( - z.object({ - command: z.nativeEnum(Command), - key: z.string(), - }), + z + .object({ + command: z.string().transform((val, ctx) => { + const negate = val.startsWith('-'); + const commandId = negate ? val.slice(1) : val; + + const result = z.nativeEnum(Command).safeParse(commandId); + if (!result.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid command: "${val}".`, + }); + return z.NEVER; + } + + return { + command: result.data, + negate, + }; + }), + key: z.string(), + }) + .transform((val) => ({ + commandEntry: val.command, + key: val.key, + })), ); /** @@ -648,15 +680,29 @@ export async function loadCustomKeybindings(): Promise<{ if (result.success) { config = new Map(defaultKeyBindingConfig); - for (const { command, key } of result.data) { + for (const { commandEntry, key } of result.data) { + const { command, negate } = commandEntry; 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]); + + if (negate) { + const updatedBindings = currentBindings.filter( + (b) => !b.equals(keyBinding), + ); + if (updatedBindings.length === currentBindings.length) { + throw new Error(`cannot remove "${key}" since it is not bound`); + } + config.set(command, updatedBindings); + } else { + // 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}`); + errors.push( + `Invalid keybinding for command "${negate ? '-' : ''}${command}": ${e}`, + ); } } } else {