mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 21:44:25 -07:00
feat(cli): support removing keybindings via '-' prefix (#22042)
This commit is contained in:
committed by
GitHub
parent
50384ab3c9
commit
7e9e196793
@@ -130,10 +130,9 @@ available combinations.
|
|||||||
|
|
||||||
## Customizing Keybindings
|
## 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
|
`keybindings.json` file in your home gemini directory (typically
|
||||||
`~/.gemini/keybindings.json`). This allows you to bind commands to additional
|
`~/.gemini/keybindings.json`).
|
||||||
key combinations. Note that default keybindings cannot be removed.
|
|
||||||
|
|
||||||
### Configuration Format
|
### Configuration Format
|
||||||
|
|
||||||
@@ -144,28 +143,57 @@ a `key` combination.
|
|||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"command": "input.submit",
|
"command": "edit.clear",
|
||||||
"key": "cmd+s"
|
"key": "cmd+l"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "edit.clear",
|
// prefix "-" to unbind a key
|
||||||
"key": "ctrl+l"
|
"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
|
- **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
|
`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+ctrl+f`.
|
||||||
`alt`/`opt`/`option`, `cmd`/`meta`).
|
|
||||||
- **Literal Characters**: Terminals often translate complex key combinations
|
- **Literal Characters**: Terminals often translate complex key combinations
|
||||||
(especially on macOS with the `Option` key) into special characters. To handle
|
(especially on macOS with the `Option` key) into special characters, losing
|
||||||
this reliably across all operating systems and SSH sessions, you can bind
|
modifier and keystroke information along the way. For example,`shift+5` might
|
||||||
directly to the literal character produced. For example, instead of trying to
|
be sent as `%`. In these cases, you must bind to the literal character `%` as
|
||||||
bind `shift+5`, bind directly to `%`.
|
bindings to `shift+5` will never fire. To see precisely what is being sent,
|
||||||
- **Special Keys**: Supported special keys include:
|
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`,
|
- **Navigation**: `up`, `down`, `left`, `right`, `home`, `end`, `pageup`,
|
||||||
`pagedown`
|
`pagedown`
|
||||||
- **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`,
|
- **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`,
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ describe('loadCustomKeybindings', () => {
|
|||||||
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
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
|
// Should still have defaults
|
||||||
expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
|
expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
|
||||||
});
|
});
|
||||||
@@ -227,4 +227,34 @@ describe('loadCustomKeybindings', () => {
|
|||||||
new KeyBinding('ctrl+c'),
|
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')]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -216,6 +216,16 @@ export class KeyBinding {
|
|||||||
!!key.cmd === !!this.cmd
|
!!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<Record<Command, string>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const keybindingsSchema = z.array(
|
const keybindingsSchema = z.array(
|
||||||
z.object({
|
z
|
||||||
command: z.nativeEnum(Command),
|
.object({
|
||||||
key: z.string(),
|
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) {
|
if (result.success) {
|
||||||
config = new Map(defaultKeyBindingConfig);
|
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) ?? [];
|
const currentBindings = config.get(command) ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keyBinding = new KeyBinding(key);
|
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) {
|
} catch (e) {
|
||||||
errors.push(`Invalid keybinding for command "${command}": ${e}`);
|
errors.push(
|
||||||
|
`Invalid keybinding for command "${negate ? '-' : ''}${command}": ${e}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user