Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6c83e5f
feat(keybindings): add types, default binding registry, and tests
IShaalan Apr 4, 2026
2dc7687
fix(keybindings): align binding IDs with spec naming convention
IShaalan Apr 4, 2026
064b25f
fix(keybindings): use cmdOrCtrl for cross-platform bindings
IShaalan Apr 4, 2026
c7dc121
feat(keybindings): add presets and resolution logic with conflict det…
IShaalan Apr 4, 2026
98cf05c
feat(keybindings): add backend persistence with atomic writes and fal…
IShaalan Apr 4, 2026
cd623cf
feat(keybindings): add frontend store with reactive resolution and pe…
IShaalan Apr 4, 2026
c240991
refactor(shortcuts): wire app-layer shortcuts to binding registry
IShaalan Apr 4, 2026
f8132b8
refactor(terminal): wire terminal shortcuts to binding registry
IShaalan Apr 4, 2026
0c8a521
feat(keybindings): interactive keybinding editor with presets and con…
IShaalan Apr 4, 2026
100dcc2
feat(keybindings): add opt-in migration banner for existing users
IShaalan Apr 4, 2026
ffa8ab1
fix(keybindings): show unbound bindings in editor with dash indicator
IShaalan Apr 4, 2026
d55512b
fix(keybindings): fix reactivity bug, add dismiss to banner, gitignor…
IShaalan Apr 4, 2026
e989b39
fix(keybindings): scope user overrides per preset
IShaalan Apr 4, 2026
cfc6029
fix(keybindings): let Escape propagate to close dialog during recording
IShaalan Apr 4, 2026
98aa5d8
fix(keybindings): improve conflict detection, perf, and code quality
IShaalan Apr 4, 2026
0bf49d7
fix(keybindings): address PR review feedback
IShaalan Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ release
.idea
.claude
.DS_Store
docs/plans
docs/
63 changes: 63 additions & 0 deletions electron/ipc/__tests__/keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { loadKeybindings, saveKeybindings } from '../keybindings.js';

describe('keybindings persistence', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keybindings-test-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('returns default config when file does not exist', () => {
const result = loadKeybindings(tmpDir);
expect(result).toEqual({ preset: 'default', overridesByPreset: {} });
});

it('saves and loads a keybinding config', () => {
const config = {
preset: 'claude-code',
overridesByPreset: {
'claude-code': {
'app.toggle-sidebar': { key: 'b', modifiers: { cmdOrCtrl: true, shift: true } },
},
},
};
saveKeybindings(tmpDir, JSON.stringify(config));
const loaded = loadKeybindings(tmpDir);
expect(loaded).toEqual(config);
});

it('falls back to default on corrupted file', () => {
fs.writeFileSync(path.join(tmpDir, 'keybindings.json'), 'not json', 'utf8');
const result = loadKeybindings(tmpDir);
expect(result).toEqual({ preset: 'default', overridesByPreset: {} });
});

it('falls back to backup on corrupted primary', () => {
const config = { preset: 'claude-code', overridesByPreset: {} };
fs.writeFileSync(path.join(tmpDir, 'keybindings.json'), 'corrupted', 'utf8');
fs.writeFileSync(path.join(tmpDir, 'keybindings.json.bak'), JSON.stringify(config), 'utf8');
const result = loadKeybindings(tmpDir);
expect(result).toEqual(config);
});

it('accepts legacy flat userOverrides format', () => {
const legacy = {
preset: 'claude-code',
userOverrides: {
'app.toggle-sidebar': { key: 'b', modifiers: { cmdOrCtrl: true, shift: true } },
},
};
fs.writeFileSync(path.join(tmpDir, 'keybindings.json'), JSON.stringify(legacy), 'utf8');
const loaded = loadKeybindings(tmpDir);
expect(loaded.preset).toBe('claude-code');
expect(loaded.userOverrides).toEqual(legacy.userOverrides);
});
});
4 changes: 4 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export enum IPC {
SaveAppState = 'save_app_state',
LoadAppState = 'load_app_state',

// Keybindings
LoadKeybindings = 'load_keybindings',
SaveKeybindings = 'save_keybindings',

// Window
WindowIsFocused = '__window_is_focused',
WindowIsMaximized = '__window_is_maximized',
Expand Down
85 changes: 85 additions & 0 deletions electron/ipc/keybindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from 'fs';
import path from 'path';

const FILENAME = 'keybindings.json';

/**
* Persisted keybinding config.
* - `overridesByPreset` is the current shape (per-preset user overrides)
* - `userOverrides` is the legacy flat shape (still accepted on load)
*/
export interface PersistedKeybindings {
preset: string;
overridesByPreset?: Record<string, Record<string, unknown>>;
/** @deprecated use overridesByPreset. Still read for backward compat. */
userOverrides?: Record<string, unknown>;
}

const DEFAULT_CONFIG: PersistedKeybindings = {
preset: 'default',
overridesByPreset: {},
};

function isValidShape(parsed: unknown): parsed is PersistedKeybindings {
if (!parsed || typeof parsed !== 'object') return false;
const obj = parsed as Record<string, unknown>;
if (typeof obj.preset !== 'string') return false;
if (obj.overridesByPreset !== undefined && typeof obj.overridesByPreset !== 'object') {
return false;
}
if (obj.userOverrides !== undefined && typeof obj.userOverrides !== 'object') return false;
return true;
}

export function loadKeybindings(dir: string): PersistedKeybindings {
const filePath = path.join(dir, FILENAME);
const bakPath = filePath + '.bak';

for (const candidate of [filePath, bakPath]) {
try {
if (fs.existsSync(candidate)) {
const content = fs.readFileSync(candidate, 'utf8');
if (content.trim()) {
const parsed: unknown = JSON.parse(content);
if (isValidShape(parsed)) {
return parsed;
}
}
}
} catch {
// Try next candidate
}
}

return { ...DEFAULT_CONFIG };
Comment thread
IShaalan marked this conversation as resolved.
}

export function saveKeybindings(dir: string, json: string): void {
const filePath = path.join(dir, FILENAME);
fs.mkdirSync(dir, { recursive: true });

// Validate JSON before writing
JSON.parse(json);

const tmpPath = filePath + '.tmp';
try {
fs.writeFileSync(tmpPath, json, 'utf8');

if (fs.existsSync(filePath)) {
try {
fs.copyFileSync(filePath, filePath + '.bak');
} catch {
/* ignore */
}
}

fs.renameSync(tmpPath, filePath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
/* ignore */
}
throw err;
}
}
20 changes: 20 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
import { createTask, deleteTask } from './tasks.js';
import { listAgents } from './agents.js';
import { saveAppState, loadAppState } from './persistence.js';
import { loadKeybindings, saveKeybindings } from './keybindings.js';
import { spawn } from 'child_process';
import { askAboutCode, cancelAskAboutCode } from './ask-code.js';
import { getSystemMonospaceFonts } from './system-fonts.js';
Expand Down Expand Up @@ -369,6 +370,25 @@ export function registerAllHandlers(win: BrowserWindow): void {
return json;
});

// --- Keybindings ---
function getKeybindingsDir(): string {
let dir = app.getPath('userData');
if (!app.isPackaged) {
const base = path.basename(dir);
dir = path.join(path.dirname(dir), `${base}-dev`);
}
return dir;
}

ipcMain.handle(IPC.LoadKeybindings, () => {
return loadKeybindings(getKeybindingsDir());
});

ipcMain.handle(IPC.SaveKeybindings, (_e, args) => {
assertString(args?.json, 'json');
saveKeybindings(getKeybindingsDir(), args.json);
});

// --- Arena persistence ---
ipcMain.handle(IPC.SaveArenaData, (_e, args) => {
assertString(args.filename, 'filename');
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const ALLOWED_CHANNELS = new Set([
// Persistence
'save_app_state',
'load_app_state',
// Keybindings
'load_keybindings',
'save_keybindings',
// Window
'__window_is_focused',
'__window_is_maximized',
Expand Down
Loading