feat(cli): customizable keyboard shortcuts (#21945)

This commit is contained in:
Tommaso Sciortino
2026-03-11 01:05:50 +00:00
committed by GitHub
parent 657f19c1f3
commit daf3701194
10 changed files with 468 additions and 210 deletions
+101 -23
View File
@@ -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'),
]);
});
});