Skip to content

Commit 217a320

Browse files
committed
Add custom keybinding remapping for global shortcuts
Let users rebind command palette, new chat, open settings, and the shortcuts dialog to their own key combos in Settings. Renderer-only shortcuts are matched in JS against the saved binding; the two that are native-menu accelerators (new chat, settings) get the menu rebuilt with the new accelerator instead, since Electron menu shortcuts can't be intercepted from JS.
1 parent 02665ac commit 217a320

12 files changed

Lines changed: 301 additions & 17 deletions

File tree

‎app/src/keybindings.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, it, expect } from "vitest";
2+
import { bindingToAccelerator } from "./keybindings";
3+
4+
describe("bindingToAccelerator", () => {
5+
it("converts a simple mod+letter binding", () => {
6+
expect(bindingToAccelerator("mod+n")).toBe("CmdOrCtrl+N");
7+
});
8+
9+
it("converts a binding with shift and a punctuation key", () => {
10+
expect(bindingToAccelerator("mod+shift+,")).toBe("CmdOrCtrl+Shift+,");
11+
});
12+
13+
it("converts a binding with alt", () => {
14+
expect(bindingToAccelerator("mod+alt+p")).toBe("CmdOrCtrl+Alt+P");
15+
});
16+
});

‎app/src/keybindings.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Only actions the native Electron menu owns an accelerator for live here —
2+
// "New chat" and "Settings" are dispatched by the OS-level menu accelerator,
3+
// so rebinding them means rebuilding the menu (see menu.ts). Renderer-only
4+
// shortcuts (command palette, shortcuts dialog) are matched in JS instead;
5+
// see frontend/src/lib/keybindings.ts for those.
6+
export type KeybindingAction = "newChat" | "openSettings";
7+
8+
export const DEFAULT_MENU_KEYBINDINGS: Record<KeybindingAction, string> = {
9+
newChat: "mod+n",
10+
openSettings: "mod+,",
11+
};
12+
13+
// Converts this app's normalized binding string ("mod+shift+n") into an
14+
// Electron accelerator ("CmdOrCtrl+Shift+N").
15+
export function bindingToAccelerator(binding: string): string {
16+
return binding
17+
.split("+")
18+
.map((part) => {
19+
if (part === "mod") return "CmdOrCtrl";
20+
if (part === "shift") return "Shift";
21+
if (part === "alt") return "Alt";
22+
if (part === "escape") return "Esc";
23+
return /^[a-z]$/.test(part) ? part.toUpperCase() : part;
24+
})
25+
.join("+");
26+
}

‎app/src/main.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,9 @@ function registerIpcHandlers(): void {
363363
ipcMain.handle("settings:save", (_event: IpcMainInvokeEvent, partial) => {
364364
const saved = settingsStore.saveSettings(partial);
365365
if (partial.ollamaHost !== undefined) ollama.setHost(saved.ollamaHost);
366+
if (partial.keybindings !== undefined) {
367+
setupMenu(() => mainWindow, () => checkForUpdatesManually(() => mainWindow), saved.keybindings);
368+
}
366369
return saved;
367370
});
368371

@@ -700,7 +703,7 @@ async function connectEnabledMcpServers(): Promise<void> {
700703

701704
app.whenReady().then(async () => {
702705
registerIpcHandlers();
703-
setupMenu(() => mainWindow, () => checkForUpdatesManually(() => mainWindow));
706+
setupMenu(() => mainWindow, () => checkForUpdatesManually(() => mainWindow), settingsStore.getSettings().keybindings);
704707
createWindow();
705708
ollama.setHost(settingsStore.getSettings().ollamaHost);
706709
ollama.setModelsDir(settingsStore.getSettings().modelsDir);

‎app/src/menu.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, dialog } from "electron";
2+
import { DEFAULT_MENU_KEYBINDINGS, bindingToAccelerator, type KeybindingAction } from "./keybindings";
23

3-
export function setupMenu(getWindow: () => BrowserWindow | null, checkForUpdates: () => void): void {
4+
export function setupMenu(
5+
getWindow: () => BrowserWindow | null,
6+
checkForUpdates: () => void,
7+
keybindings: Partial<Record<KeybindingAction, string>> = {}
8+
): void {
9+
const bindings = { ...DEFAULT_MENU_KEYBINDINGS, ...keybindings };
410
const isMac = process.platform === "darwin";
511

612
function send(channel: string) {
@@ -40,8 +46,16 @@ export function setupMenu(getWindow: () => BrowserWindow | null, checkForUpdates
4046
{
4147
label: "File",
4248
submenu: [
43-
{ label: "New Chat", accelerator: "CmdOrCtrl+N", click: () => send("menu:new-chat") },
44-
{ label: "Settings", accelerator: "CmdOrCtrl+,", click: () => send("menu:open-settings") },
49+
{
50+
label: "New Chat",
51+
accelerator: bindingToAccelerator(bindings.newChat),
52+
click: () => send("menu:new-chat"),
53+
},
54+
{
55+
label: "Settings",
56+
accelerator: bindingToAccelerator(bindings.openSettings),
57+
click: () => send("menu:open-settings"),
58+
},
4559
{ type: "separator" },
4660
isMac ? { role: "close" } : { role: "quit" },
4761
],

‎app/src/settings-store.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ export interface AppSettings {
7373
// Set once the first-run provider setup wizard has been completed (or
7474
// explicitly skipped), so it doesn't reappear on every launch.
7575
onboardingComplete?: boolean;
76+
// User-remapped shortcuts, keyed by action name, normalized as
77+
// "mod+shift+k" (mod = Ctrl/Cmd). Covers both menu-accelerator actions
78+
// (KeybindingAction, above) and renderer-only ones the frontend matches
79+
// in JS (command palette, shortcuts dialog) — this store doesn't
80+
// distinguish the two, it just persists whatever the renderer sends.
81+
keybindings?: Record<string, string>;
7682
}
7783

7884
const DEFAULTS: AppSettings = {

‎frontend/src/components/keyboard-shortcuts-dialog.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
22
import { useI18n } from "@/lib/i18n";
3-
4-
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
5-
const mod = isMac ? "⌘" : "Ctrl";
3+
import { DEFAULT_KEYBINDINGS, formatBindingForDisplay, type KeybindingAction } from "@/lib/keybindings";
64

75
function Shortcut({ label, keys }: { label: string; keys: string[] }) {
86
return (
@@ -25,9 +23,11 @@ function Shortcut({ label, keys }: { label: string; keys: string[] }) {
2523
export function KeyboardShortcutsDialog({
2624
open,
2725
onOpenChange,
26+
keybindings = DEFAULT_KEYBINDINGS,
2827
}: {
2928
open: boolean;
3029
onOpenChange: (open: boolean) => void;
30+
keybindings?: Record<KeybindingAction, string>;
3131
}) {
3232
const { t } = useI18n();
3333

@@ -39,10 +39,10 @@ export function KeyboardShortcutsDialog({
3939
<DialogDescription>{t.keyboardShortcutsHelp}</DialogDescription>
4040
</DialogHeader>
4141
<div className="flex flex-col divide-y divide-border">
42-
<Shortcut label={t.shortcutCommandPalette} keys={[mod, "K"]} />
43-
<Shortcut label={t.shortcutNewChat} keys={[mod, "N"]} />
44-
<Shortcut label={t.shortcutSettings} keys={[mod, ","]} />
45-
<Shortcut label={t.shortcutShowShortcuts} keys={[mod, "/"]} />
42+
<Shortcut label={t.shortcutCommandPalette} keys={[formatBindingForDisplay(keybindings.commandPalette)]} />
43+
<Shortcut label={t.shortcutNewChat} keys={[formatBindingForDisplay(keybindings.newChat)]} />
44+
<Shortcut label={t.shortcutSettings} keys={[formatBindingForDisplay(keybindings.openSettings)]} />
45+
<Shortcut label={t.shortcutShowShortcuts} keys={[formatBindingForDisplay(keybindings.showShortcuts)]} />
4646
<Shortcut label={t.shortcutSend} keys={["Enter"]} />
4747
<Shortcut label={t.shortcutNewline} keys={["Shift", "Enter"]} />
4848
<Shortcut label={t.shortcutStopGenerating} keys={["Esc"]} />

‎frontend/src/components/layout.tsx‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { cn } from "@/lib/utils";
3333
import type { ChatOptions, ChatSession, Project, PromptPreset } from "@/types/electron";
3434
import { extractVariables, fillTemplate } from "@/lib/prompt-templates";
3535
import { PromptVariableDialog } from "@/components/prompt-variable-dialog";
36+
import { DEFAULT_KEYBINDINGS, matchesBinding, subscribeKeybindings, type KeybindingAction } from "@/lib/keybindings";
3637

3738
function SessionRow({
3839
session,
@@ -515,12 +516,15 @@ export default function Layout() {
515516
const [paletteOpen, setPaletteOpen] = useState(false);
516517
const [shortcutsOpen, setShortcutsOpen] = useState(false);
517518
const [showOnboarding, setShowOnboarding] = useState(false);
519+
const [keybindings, setKeybindings] = useState<Record<KeybindingAction, string>>(DEFAULT_KEYBINDINGS);
518520

519521
useEffect(() => {
520522
if (!hasApi) return;
521523
window.api.settings.get().then((s) => {
522524
setShowOnboarding(!s.onboardingComplete);
525+
setKeybindings({ ...DEFAULT_KEYBINDINGS, ...s.keybindings });
523526
});
527+
return subscribeKeybindings(setKeybindings);
524528
}, [hasApi]);
525529

526530
const query = search.trim().toLowerCase();
@@ -583,21 +587,24 @@ export default function Layout() {
583587
// eslint-disable-next-line react-hooks/exhaustive-deps
584588
}, [hasApi]);
585589

586-
// Ctrl/Cmd+K opens the command palette, Ctrl/Cmd+/ shows the shortcuts
587-
// cheat-sheet, both from anywhere in the app.
590+
// Command palette and the shortcuts cheat-sheet are renderer-only (no
591+
// native menu entry), so they're matched here against the user's
592+
// (possibly remapped) bindings rather than a hardcoded key check.
593+
// "New chat"/"Settings" are remapped instead by rebuilding the native
594+
// menu's accelerator — see app/src/menu.ts — so they're not handled here.
588595
useEffect(() => {
589596
function onKeyDown(e: KeyboardEvent) {
590-
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
597+
if (matchesBinding(e, keybindings.commandPalette)) {
591598
e.preventDefault();
592599
setPaletteOpen((o) => !o);
593-
} else if ((e.ctrlKey || e.metaKey) && e.key === "/") {
600+
} else if (matchesBinding(e, keybindings.showShortcuts)) {
594601
e.preventDefault();
595602
setShortcutsOpen((o) => !o);
596603
}
597604
}
598605
window.addEventListener("keydown", onKeyDown);
599606
return () => window.removeEventListener("keydown", onKeyDown);
600-
}, []);
607+
}, [keybindings]);
601608

602609
async function handleDelete(e: React.MouseEvent, id: string) {
603610
e.stopPropagation();
@@ -762,7 +769,7 @@ export default function Layout() {
762769
onNavigateSettings={() => navigate("/settings")}
763770
onNavigateCompare={() => navigate("/compare")}
764771
/>
765-
<KeyboardShortcutsDialog open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
772+
<KeyboardShortcutsDialog open={shortcutsOpen} onOpenChange={setShortcutsOpen} keybindings={keybindings} />
766773
<OnboardingWizard open={showOnboarding} onDone={() => setShowOnboarding(false)} />
767774
</div>
768775
);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from "vitest";
2+
import { eventToBindingString, matchesBinding, formatBindingForDisplay } from "./keybindings";
3+
4+
function key(overrides: Partial<{ key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; altKey: boolean }>) {
5+
return { key: "", ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, ...overrides };
6+
}
7+
8+
describe("eventToBindingString", () => {
9+
it("normalizes ctrl and meta to the same 'mod' token", () => {
10+
expect(eventToBindingString(key({ key: "k", ctrlKey: true }))).toBe("mod+k");
11+
expect(eventToBindingString(key({ key: "k", metaKey: true }))).toBe("mod+k");
12+
});
13+
14+
it("includes shift and alt in a fixed order", () => {
15+
expect(eventToBindingString(key({ key: "p", ctrlKey: true, shiftKey: true, altKey: true }))).toBe(
16+
"mod+shift+alt+p"
17+
);
18+
});
19+
20+
it("returns null for a bare modifier keypress", () => {
21+
expect(eventToBindingString(key({ key: "Control", ctrlKey: true }))).toBeNull();
22+
});
23+
});
24+
25+
describe("matchesBinding", () => {
26+
it("matches a KeyboardEvent-shaped object against a normalized binding", () => {
27+
const e = { key: "k", ctrlKey: true, metaKey: false, shiftKey: false, altKey: false } as KeyboardEvent;
28+
expect(matchesBinding(e, "mod+k")).toBe(true);
29+
expect(matchesBinding(e, "mod+n")).toBe(false);
30+
});
31+
});
32+
33+
describe("formatBindingForDisplay", () => {
34+
it("renders a single-letter binding in uppercase", () => {
35+
expect(formatBindingForDisplay("mod+n")).toMatch(/N$/);
36+
});
37+
});

‎frontend/src/lib/keybindings.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
export type KeybindingAction = "commandPalette" | "newChat" | "openSettings" | "showShortcuts";
2+
3+
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
4+
5+
export const DEFAULT_KEYBINDINGS: Record<KeybindingAction, string> = {
6+
commandPalette: "mod+k",
7+
newChat: "mod+n",
8+
openSettings: "mod+,",
9+
showShortcuts: "mod+/",
10+
};
11+
12+
export const KEYBINDING_ACTIONS: KeybindingAction[] = ["commandPalette", "newChat", "openSettings", "showShortcuts"];
13+
14+
// Canonical form: modifier tokens (mod, shift, alt) in that fixed order,
15+
// lowercased, joined by "+", followed by the key itself. Two independently
16+
// captured keydowns for "the same" shortcut always normalize to the same
17+
// string, which is what makes them comparable/storable at all.
18+
export function eventToBindingString(e: { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; altKey: boolean }): string | null {
19+
if (["Control", "Meta", "Shift", "Alt"].includes(e.key)) return null;
20+
const parts: string[] = [];
21+
if (e.ctrlKey || e.metaKey) parts.push("mod");
22+
if (e.shiftKey) parts.push("shift");
23+
if (e.altKey) parts.push("alt");
24+
parts.push(e.key.toLowerCase());
25+
return parts.join("+");
26+
}
27+
28+
export function matchesBinding(e: KeyboardEvent, binding: string): boolean {
29+
return eventToBindingString(e) === binding;
30+
}
31+
32+
export function formatBindingForDisplay(binding: string): string {
33+
return binding
34+
.split("+")
35+
.map((part) => {
36+
if (part === "mod") return isMac ? "⌘" : "Ctrl";
37+
if (part === "shift") return "Shift";
38+
if (part === "alt") return isMac ? "⌥" : "Alt";
39+
if (part === "escape") return "Esc";
40+
return part.length === 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1);
41+
})
42+
.join(isMac ? "" : "+");
43+
}
44+
45+
type Listener = (bindings: Record<KeybindingAction, string>) => void;
46+
const listeners = new Set<Listener>();
47+
48+
// Settings.tsx saves keybindings while Layout's keydown handler (which
49+
// actually matches them) lives outside that component tree and doesn't
50+
// remount on navigation — a plain in-memory pub-sub is the simplest way to
51+
// push a change across without standing up a whole settings context just
52+
// for this one field.
53+
export function notifyKeybindingsChanged(bindings: Record<KeybindingAction, string>): void {
54+
listeners.forEach((l) => l(bindings));
55+
}
56+
57+
export function subscribeKeybindings(listener: Listener): () => void {
58+
listeners.add(listener);
59+
return () => listeners.delete(listener);
60+
}

‎frontend/src/lib/translations.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ export interface Dictionary {
244244
tools: string;
245245
noneConnected: string;
246246
appMemoryUsage: string;
247+
keybindings: string;
248+
keybindingsDescription: string;
249+
pressAKey: string;
250+
recordShortcut: string;
251+
reset: string;
252+
keybindingConflict: string;
247253
copied: string;
248254
openLogsFolder: string;
249255
checkForUpdates: string;
@@ -508,6 +514,12 @@ export const en: Dictionary = {
508514
tools: "tools",
509515
noneConnected: "None connected",
510516
appMemoryUsage: "App memory usage",
517+
keybindings: "Keybindings",
518+
keybindingsDescription: "Rebind the app's global shortcuts to whatever combo you'd rather use.",
519+
pressAKey: "Press a key combo… (Esc to cancel)",
520+
recordShortcut: "Record shortcut",
521+
reset: "Reset",
522+
keybindingConflict: "{key} is already used by another shortcut.",
511523
copyDiagnosticInfo: "Copy diagnostic info",
512524
copied: "Copied",
513525
openLogsFolder: "Open logs folder",
@@ -782,6 +794,12 @@ export const tr: Dictionary = {
782794
tools: "araç",
783795
noneConnected: "Bağlı yok",
784796
appMemoryUsage: "Uygulama bellek kullanımı",
797+
keybindings: "Kısayol tuşları",
798+
keybindingsDescription: "Uygulamanın genel kısayollarını istediğiniz tuş kombinasyonuna yeniden atayın.",
799+
pressAKey: "Bir tuş kombinasyonuna basın… (İptal için Esc)",
800+
recordShortcut: "Kısayolu kaydet",
801+
reset: "Sıfırla",
802+
keybindingConflict: "{key} zaten başka bir kısayol tarafından kullanılıyor.",
785803
copied: "Kopyalandı",
786804
openLogsFolder: "Günlük klasörünü aç",
787805
checkForUpdates: "Güncellemeleri denetle",

0 commit comments

Comments
 (0)