diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx
index fa8dcae1ba..70bda37182 100644
--- a/docs/docs/keybindings.mdx
+++ b/docs/docs/keybindings.mdx
@@ -43,6 +43,8 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch
| | Focus WaveAI input |
| | Switch to block number |
| / | Move left, right, up, down between blocks |
+| | Cycle block focus forward (CW) |
+| | Cycle block focus backward (CCW) |
| | Replace the current block with a launcher block |
| | Switch to tab number |
| / | Switch tab left |
@@ -105,7 +107,84 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch
| | Scroll up one page |
| | Scroll down one page |
-## Customizeable Systemwide Global Hotkey
+## Customizing Keybindings
+
+You can override, remap, or disable any default keybinding by editing `keybindings.json` in the Wave config directory (`~/.config/waveterm/keybindings.json`). You can also edit this file from within Wave by opening the Config editor and selecting "Keybindings" in the sidebar.
+
+The file uses a VS Code-style array format. Each entry maps a key combination to an action ID. Only overrides are needed — all defaults apply automatically.
+
+### Key Syntax
+
+Key combinations use colon-separated format:
+
+- **Modifiers:** `Cmd` (macOS Command / Windows-Linux Meta), `Ctrl`, `Shift`, `Alt` (macOS Option), `Meta`
+- **Special keys:** `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Home`, `End`, `Escape`, `Enter`, `Tab`, `Space`, `Backspace`, `Delete`
+- **Letters and digits:** Lowercase (`a`–`z`), digits (`0`–`9`)
+
+### Examples
+
+**Rebind a key:** Change "new tab" from to :
+```json
+[
+ { "key": "Cmd:Shift:t", "command": "tab:new" }
+]
+```
+
+**Disable a keybinding:** Remove close block:
+```json
+[
+ { "command": "-block:close" }
+]
+```
+
+You can also set `key` to `null` to unbind:
+```json
+[
+ { "key": null, "command": "block:close" }
+]
+```
+
+**Swap two keys:**
+```json
+[
+ { "key": "Cmd:d", "command": "block:splitdown" },
+ { "key": "Cmd:Shift:d", "command": "block:splitright" }
+]
+```
+
+### Action IDs
+
+| Action ID | Default Key | Description |
+| --- | --- | --- |
+| `tab:new` | | Open a new tab |
+| `tab:close` | | Close the current tab |
+| `tab:prev` | | Switch to previous tab |
+| `tab:next` | | Switch to next tab |
+| `tab:switchto1`–`tab:switchto9` | – | Switch to tab N |
+| `block:new` | | Open a new block |
+| `block:close` | | Close the current block |
+| `block:splitright` | | Split right |
+| `block:splitdown` | | Split down |
+| `block:magnify` | | Magnify/unmagnify block |
+| `block:refocus` | | Refocus the current block |
+| `block:navup/navdown/navleft/navright` | | Navigate between blocks |
+| `block:navcw` | | Cycle block focus forward (CW) |
+| `block:navccw` | | Cycle block focus backward (CCW) |
+| `block:switchto1`–`block:switchto9` | – | Switch to block N |
+| `block:switchtoai` | | Focus WaveAI input |
+| `block:replacewithlauncher` | | Replace block with launcher |
+| `app:search` | | Find/search |
+| `app:openconnection` | | Open connection switcher |
+| `app:toggleaipanel` | | Toggle WaveAI panel |
+| `app:togglewidgetssidebar` | | Toggle widgets sidebar |
+| `app:settings` | | Open settings |
+| `term:togglemultiinput` | | Toggle terminal multi-input |
+| `generic:cancel` | | Close modals/search |
+| `block:splitchord` | | Initiate split chord |
+
+Changes take effect immediately — no restart required.
+
+## Customizable Systemwide Global Hotkey
Wave allows setting a custom global hotkey to focus your most recent window from anywhere in your computer. For more information on this, see [the config docs](./config#customizable-systemwide-global-hotkey).
diff --git a/frontend/app/monaco/schemaendpoints.ts b/frontend/app/monaco/schemaendpoints.ts
index 5365d1c739..3686e31aec 100644
--- a/frontend/app/monaco/schemaendpoints.ts
+++ b/frontend/app/monaco/schemaendpoints.ts
@@ -4,6 +4,7 @@
import aipresetsSchema from "../../../schema/aipresets.json";
import backgroundsSchema from "../../../schema/backgrounds.json";
import connectionsSchema from "../../../schema/connections.json";
+import keybindingsSchema from "../../../schema/keybindings.json";
import settingsSchema from "../../../schema/settings.json";
import waveaiSchema from "../../../schema/waveai.json";
import widgetsSchema from "../../../schema/widgets.json";
@@ -45,6 +46,11 @@ const MonacoSchemas: SchemaInfo[] = [
fileMatch: ["*/WAVECONFIGPATH/widgets.json"],
schema: widgetsSchema,
},
+ {
+ uri: "wave://schema/keybindings.json",
+ fileMatch: ["*/WAVECONFIGPATH/keybindings.json"],
+ schema: keybindingsSchema,
+ },
];
export { MonacoSchemas };
diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts
index 01fe12800e..9b0dcd3a4b 100644
--- a/frontend/app/store/global-atoms.ts
+++ b/frontend/app/store/global-atoms.ts
@@ -59,6 +59,18 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const settingsAtom = atom((get) => {
return get(fullConfigAtom)?.settings ?? {};
}) as Atom;
+ const keybindingsAtom = atom((get) => {
+ const fullConfig = get(fullConfigAtom);
+ if (!fullConfig?.keybindings) return [];
+ try {
+ const raw = fullConfig.keybindings;
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ console.warn("Failed to parse keybindings.json");
+ return [];
+ }
+ });
const hasCustomAIPresetsAtom = atom((get) => {
const fullConfig = get(fullConfigAtom);
if (!fullConfig?.presets) {
@@ -136,6 +148,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
fullConfigAtom,
waveaiModeConfigAtom,
settingsAtom,
+ keybindingsAtom,
hasCustomAIPresetsAtom,
hasConfigErrors,
staticTabId: staticTabIdAtom,
diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts
index afa5209116..b3b5c11b8a 100644
--- a/frontend/app/store/keymodel.ts
+++ b/frontend/app/store/keymodel.ts
@@ -33,9 +33,30 @@ import { isBuilderWindow, isTabWindow } from "./windowtype";
type KeyHandler = (event: WaveKeyboardEvent) => boolean;
+type KeybindingEntry = {
+ key: string | null;
+ command: string;
+};
+
+type ActionDef = {
+ id: string;
+ defaultKeys: string[];
+ handler: KeyHandler;
+};
+
+type ChordActionDef = {
+ id: string;
+ parentId: string;
+ defaultKey: string;
+ handler: KeyHandler;
+};
+
+type KeyMapEntry = { key: string; handler: T | null };
+type ChordEntry = { key: string; subKeys: KeyMapEntry[] };
+
const simpleControlShiftAtom = jotai.atom(false);
-const globalKeyMap = new Map boolean>();
-const globalChordMap = new Map>();
+let globalKeyBindings: KeyMapEntry[] = [];
+let globalChordBindings: ChordEntry[] = [];
let globalKeybindingsDisabled = false;
// track current chord state and timeout (for resetting)
@@ -239,6 +260,24 @@ function switchBlockByBlockNum(index: number) {
}, 10);
}
+function cycleBlockFocus(delta: 1 | -1) {
+ const layoutModel = getLayoutModelForStaticTab();
+ if (!layoutModel) {
+ return;
+ }
+ const leafOrder = globalStore.get(layoutModel.leafOrder);
+ if (leafOrder.length === 0) {
+ return;
+ }
+ const focusedNodeId = layoutModel.focusedNodeId;
+ const curIdx = leafOrder.findIndex((e) => e.nodeid === focusedNodeId);
+ const nextIdx = (curIdx + delta + leafOrder.length) % leafOrder.length;
+ layoutModel.focusNode(leafOrder[nextIdx].nodeid);
+ setTimeout(() => {
+ globalRefocus();
+ }, 10);
+}
+
function switchBlockInDirection(direction: NavigateDirection) {
const layoutModel = getLayoutModelForStaticTab();
const focusType = FocusManager.getInstance().getFocusType();
@@ -402,12 +441,16 @@ async function handleSplitVertical(position: "before" | "after") {
let lastHandledEvent: KeyboardEvent | null = null;
-// returns [keymatch, T]
-function checkKeyMap(waveEvent: WaveKeyboardEvent, keyMap: Map): [string, T] {
- for (const key of keyMap.keys()) {
- if (keyutil.checkKeyPressed(waveEvent, key)) {
- const val = keyMap.get(key);
- return [key, val];
+// returns [keymatch, T] — iterates in reverse so later entries (user overrides) win
+// a null handler means the key was explicitly unbound (via -command)
+function checkKeyArray(waveEvent: WaveKeyboardEvent, entries: KeyMapEntry[]): [string, T] {
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const entry = entries[i];
+ if (keyutil.checkKeyPressed(waveEvent, entry.key)) {
+ if (entry.handler == null) {
+ return [null, null]; // unbound
+ }
+ return [entry.key, entry.handler];
}
}
return [null, null];
@@ -424,25 +467,28 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
lastHandledEvent = nativeEvent;
if (activeChord) {
console.log("handle activeChord", activeChord);
- // If we're in chord mode, look for the second key.
- const chordBindings = globalChordMap.get(activeChord);
- const [, handler] = checkKeyMap(waveEvent, chordBindings);
- if (handler) {
- resetChord();
- return handler(waveEvent);
- } else {
- // invalid chord; reset state and consume key
- resetChord();
- return true;
+ // If we're in chord mode, look for the second key in the matching chord's sub-keys.
+ const chordEntry = globalChordBindings.find((c) => c.key === activeChord);
+ if (chordEntry) {
+ const [, handler] = checkKeyArray(waveEvent, chordEntry.subKeys);
+ if (handler) {
+ resetChord();
+ return handler(waveEvent);
+ }
}
- }
- const [chordKeyMatch] = checkKeyMap(waveEvent, globalChordMap);
- if (chordKeyMatch) {
- setActiveChord(chordKeyMatch);
+ // invalid chord; reset state and consume key
+ resetChord();
return true;
}
+ // Check if this key initiates a chord
+ for (const chord of globalChordBindings) {
+ if (keyutil.checkKeyPressed(waveEvent, chord.key)) {
+ setActiveChord(chord.key);
+ return true;
+ }
+ }
- const [, globalHandler] = checkKeyMap(waveEvent, globalKeyMap);
+ const [, globalHandler] = checkKeyArray(waveEvent, globalKeyBindings);
if (globalHandler) {
const handled = globalHandler(waveEvent);
if (handled) {
@@ -500,284 +546,464 @@ function countTermBlocks(): number {
return count;
}
-function registerGlobalKeys() {
- globalKeyMap.set("Cmd:]", () => {
- switchTab(1);
- return true;
- });
- globalKeyMap.set("Shift:Cmd:]", () => {
- switchTab(1);
- return true;
- });
- globalKeyMap.set("Cmd:[", () => {
- switchTab(-1);
- return true;
- });
- globalKeyMap.set("Shift:Cmd:[", () => {
- switchTab(-1);
- return true;
- });
- globalKeyMap.set("Cmd:n", () => {
- handleCmdN();
- return true;
- });
- globalKeyMap.set("Cmd:d", () => {
- handleSplitHorizontal("after");
- return true;
- });
- globalKeyMap.set("Shift:Cmd:d", () => {
- handleSplitVertical("after");
- return true;
- });
- globalKeyMap.set("Cmd:i", () => {
- handleCmdI();
- return true;
- });
- globalKeyMap.set("Cmd:t", () => {
- createTab();
- return true;
- });
- globalKeyMap.set("Cmd:w", () => {
- genericClose();
- return true;
- });
- globalKeyMap.set("Cmd:Shift:w", () => {
- simpleCloseStaticTab();
- return true;
- });
- globalKeyMap.set("Cmd:m", () => {
- const layoutModel = getLayoutModelForStaticTab();
- const focusedNode = globalStore.get(layoutModel.focusedNode);
- if (focusedNode != null) {
- layoutModel.magnifyNodeToggle(focusedNode.id);
- }
- return true;
- });
- globalKeyMap.set("Ctrl:Shift:ArrowUp", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
- }
- switchBlockInDirection(NavigateDirection.Up);
- return true;
- });
- globalKeyMap.set("Ctrl:Shift:ArrowDown", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
- }
- switchBlockInDirection(NavigateDirection.Down);
- return true;
- });
- globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
- }
- switchBlockInDirection(NavigateDirection.Left);
- return true;
- });
- globalKeyMap.set("Ctrl:Shift:ArrowRight", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
- }
- switchBlockInDirection(NavigateDirection.Right);
- return true;
- });
- // Vim-style aliases for block focus navigation.
- globalKeyMap.set("Ctrl:Shift:h", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
- }
- switchBlockInDirection(NavigateDirection.Left);
- return true;
- });
- globalKeyMap.set("Ctrl:Shift:j", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
+function activateSearch(event: WaveKeyboardEvent): boolean {
+ const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
+ // Ctrl+f is reserved in most shells
+ if (event.control && bcm.viewModel.viewType == "term") {
+ return false;
+ }
+ if (bcm.viewModel.searchAtoms) {
+ if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
+ // Already open — increment the focusInput counter so this block's
+ // SearchComponent focuses its own input (avoids a global DOM query
+ // that could target the wrong block when multiple searches are open).
+ const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number;
+ globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1);
+ } else {
+ globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
}
- switchBlockInDirection(NavigateDirection.Down);
return true;
- });
- globalKeyMap.set("Ctrl:Shift:k", () => {
- const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
- if (disableCtrlShiftArrows) {
- return false;
- }
- switchBlockInDirection(NavigateDirection.Up);
+ }
+ return false;
+}
+
+function deactivateSearch(): boolean {
+ const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
+ if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
+ globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);
return true;
- });
- globalKeyMap.set("Ctrl:Shift:l", () => {
+ }
+ return false;
+}
+
+function makeBlockNavHandler(direction: NavigateDirection): KeyHandler {
+ return () => {
const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows"));
if (disableCtrlShiftArrows) {
return false;
}
- switchBlockInDirection(NavigateDirection.Right);
+ switchBlockInDirection(direction);
return true;
- });
- globalKeyMap.set("Ctrl:Shift:x", () => {
- const blockId = getFocusedBlockId();
- if (blockId == null) {
+ };
+}
+
+const defaultActions: ActionDef[] = [
+ {
+ id: "tab:next",
+ defaultKeys: ["Cmd:]", "Shift:Cmd:]"],
+ handler: () => {
+ switchTab(1);
return true;
- }
- replaceBlock(
- blockId,
- {
- meta: {
- view: "launcher",
- },
- },
- true
- );
- return true;
- });
- globalKeyMap.set("Cmd:g", () => {
- const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
- if (bcm.openSwitchConnection != null) {
- recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" });
- bcm.openSwitchConnection();
+ },
+ },
+ {
+ id: "tab:prev",
+ defaultKeys: ["Cmd:[", "Shift:Cmd:["],
+ handler: () => {
+ switchTab(-1);
return true;
- }
- });
- globalKeyMap.set("Ctrl:Shift:i", () => {
- const tabModel = getActiveTabModel();
- if (tabModel == null) {
+ },
+ },
+ {
+ id: "block:new",
+ defaultKeys: ["Cmd:n"],
+ handler: () => {
+ handleCmdN();
return true;
- }
- const curMI = globalStore.get(tabModel.isTermMultiInput);
- if (!curMI && countTermBlocks() <= 1) {
- // don't turn on multi-input unless there are 2 or more basic term blocks
+ },
+ },
+ {
+ id: "block:splitright",
+ defaultKeys: ["Cmd:d"],
+ handler: () => {
+ handleSplitHorizontal("after");
return true;
- }
- globalStore.set(tabModel.isTermMultiInput, !curMI);
- return true;
- });
- for (let idx = 1; idx <= 9; idx++) {
- globalKeyMap.set(`Cmd:${idx}`, () => {
- switchTabAbs(idx);
+ },
+ },
+ {
+ id: "block:splitdown",
+ defaultKeys: ["Shift:Cmd:d"],
+ handler: () => {
+ handleSplitVertical("after");
return true;
- });
- globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => {
- switchBlockByBlockNum(idx);
+ },
+ },
+ {
+ id: "block:refocus",
+ defaultKeys: ["Cmd:i"],
+ handler: () => {
+ handleCmdI();
return true;
- });
- globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => {
- switchBlockByBlockNum(idx);
+ },
+ },
+ {
+ id: "tab:new",
+ defaultKeys: ["Cmd:t"],
+ handler: () => {
+ createTab();
return true;
- });
- }
- if (isWindows()) {
- globalKeyMap.set("Alt:c{Digit0}", () => {
- WaveAIModel.getInstance().focusInput();
+ },
+ },
+ {
+ id: "block:close",
+ defaultKeys: ["Cmd:w"],
+ handler: () => {
+ genericClose();
return true;
- });
- globalKeyMap.set("Alt:c{Numpad0}", () => {
- WaveAIModel.getInstance().focusInput();
+ },
+ },
+ {
+ id: "tab:close",
+ defaultKeys: ["Cmd:Shift:w"],
+ handler: () => {
+ simpleCloseStaticTab();
return true;
- });
- } else {
- globalKeyMap.set("Ctrl:Shift:c{Digit0}", () => {
- WaveAIModel.getInstance().focusInput();
+ },
+ },
+ {
+ id: "block:magnify",
+ defaultKeys: ["Cmd:m"],
+ handler: () => {
+ const layoutModel = getLayoutModelForStaticTab();
+ const focusedNode = globalStore.get(layoutModel.focusedNode);
+ if (focusedNode != null) {
+ layoutModel.magnifyNodeToggle(focusedNode.id);
+ }
return true;
- });
- globalKeyMap.set("Ctrl:Shift:c{Numpad0}", () => {
- WaveAIModel.getInstance().focusInput();
+ },
+ },
+ {
+ id: "block:navup",
+ defaultKeys: ["Ctrl:Shift:ArrowUp", "Ctrl:Shift:k"],
+ handler: makeBlockNavHandler(NavigateDirection.Up),
+ },
+ {
+ id: "block:navdown",
+ defaultKeys: ["Ctrl:Shift:ArrowDown", "Ctrl:Shift:j"],
+ handler: makeBlockNavHandler(NavigateDirection.Down),
+ },
+ {
+ id: "block:navleft",
+ defaultKeys: ["Ctrl:Shift:ArrowLeft", "Ctrl:Shift:h"],
+ handler: makeBlockNavHandler(NavigateDirection.Left),
+ },
+ {
+ id: "block:navright",
+ defaultKeys: ["Ctrl:Shift:ArrowRight", "Ctrl:Shift:l"],
+ handler: makeBlockNavHandler(NavigateDirection.Right),
+ },
+ {
+ id: "block:navcw",
+ defaultKeys: ["Ctrl:Shift:]"],
+ handler: () => {
+ cycleBlockFocus(1);
return true;
- });
- }
- function activateSearch(event: WaveKeyboardEvent): boolean {
- const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
- // Ctrl+f is reserved in most shells
- if (event.control && bcm.viewModel.viewType == "term") {
+ },
+ },
+ {
+ id: "block:navccw",
+ defaultKeys: ["Ctrl:Shift:["],
+ handler: () => {
+ cycleBlockFocus(-1);
+ return true;
+ },
+ },
+ {
+ id: "block:replacewithlauncher",
+ defaultKeys: ["Ctrl:Shift:x"],
+ handler: () => {
+ const blockId = getFocusedBlockId();
+ if (blockId == null) {
+ return true;
+ }
+ replaceBlock(
+ blockId,
+ {
+ meta: {
+ view: "launcher",
+ },
+ },
+ true
+ );
+ return true;
+ },
+ },
+ {
+ id: "app:openconnection",
+ defaultKeys: ["Cmd:g"],
+ handler: () => {
+ const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
+ if (bcm.openSwitchConnection != null) {
+ recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" });
+ bcm.openSwitchConnection();
+ return true;
+ }
return false;
- }
- if (bcm.viewModel.searchAtoms) {
- if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
- // Already open — increment the focusInput counter so this block's
- // SearchComponent focuses its own input (avoids a global DOM query
- // that could target the wrong block when multiple searches are open).
- const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number;
- globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1);
- } else {
- globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
+ },
+ },
+ {
+ id: "term:togglemultiinput",
+ defaultKeys: ["Ctrl:Shift:i"],
+ handler: () => {
+ const tabModel = getActiveTabModel();
+ if (tabModel == null) {
+ return true;
+ }
+ const curMI = globalStore.get(tabModel.isTermMultiInput);
+ if (!curMI && countTermBlocks() <= 1) {
+ // don't turn on multi-input unless there are 2 or more basic term blocks
+ return true;
}
+ globalStore.set(tabModel.isTermMultiInput, !curMI);
return true;
- }
- return false;
- }
- function deactivateSearch(): boolean {
- const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
- if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
- globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);
+ },
+ },
+ {
+ id: "app:search",
+ defaultKeys: ["Cmd:f"],
+ handler: activateSearch,
+ },
+ {
+ id: "generic:cancel",
+ defaultKeys: ["Escape"],
+ handler: () => {
+ if (modalsModel.hasOpenModals()) {
+ modalsModel.popModal();
+ return true;
+ }
+ if (deactivateSearch()) {
+ return true;
+ }
+ return false;
+ },
+ },
+ {
+ id: "app:toggleaipanel",
+ defaultKeys: ["Cmd:Shift:a"],
+ handler: () => {
+ const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();
+ WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible);
return true;
+ },
+ },
+ {
+ id: "app:togglewidgetssidebar",
+ defaultKeys: ["Cmd:b"],
+ handler: () => {
+ const current = WorkspaceLayoutModel.getInstance().getWidgetsSidebarVisible();
+ WorkspaceLayoutModel.getInstance().setWidgetsSidebarVisible(!current);
+ return true;
+ },
+ },
+ {
+ id: "app:settings",
+ defaultKeys: ["Cmd:,"],
+ handler: () => {
+ fireAndForget(async () => {
+ await createBlock({ meta: { view: "waveconfig" } }, false, true);
+ });
+ return true;
+ },
+ },
+ // Numbered tab/block switch keys (1-9)
+ ...Array.from({ length: 9 }, (_, i) => {
+ const idx = i + 1;
+ return [
+ {
+ id: `tab:switchto${idx}`,
+ defaultKeys: [`Cmd:${idx}`],
+ handler: () => {
+ switchTabAbs(idx);
+ return true;
+ },
+ } as ActionDef,
+ {
+ id: `block:switchto${idx}`,
+ defaultKeys: [`Ctrl:Shift:c{Digit${idx}}`, `Ctrl:Shift:c{Numpad${idx}}`],
+ handler: () => {
+ switchBlockByBlockNum(idx);
+ return true;
+ },
+ } as ActionDef,
+ ];
+ }).flat(),
+ // AI focus (block 0) — platform-dependent keys
+ {
+ id: "block:switchtoai",
+ defaultKeys: isWindows()
+ ? ["Alt:c{Digit0}", "Alt:c{Numpad0}"]
+ : ["Ctrl:Shift:c{Digit0}", "Ctrl:Shift:c{Numpad0}"],
+ handler: () => {
+ WaveAIModel.getInstance().focusInput();
+ return true;
+ },
+ },
+ // Chord initiator for block splitting
+ {
+ id: "block:splitchord",
+ defaultKeys: ["Ctrl:Shift:s"],
+ handler: () => true,
+ },
+];
+
+const defaultChordActions: ChordActionDef[] = [
+ {
+ id: "block:splitchordup",
+ parentId: "block:splitchord",
+ defaultKey: "ArrowUp",
+ handler: () => {
+ handleSplitVertical("before");
+ return true;
+ },
+ },
+ {
+ id: "block:splitchorddown",
+ parentId: "block:splitchord",
+ defaultKey: "ArrowDown",
+ handler: () => {
+ handleSplitVertical("after");
+ return true;
+ },
+ },
+ {
+ id: "block:splitchordleft",
+ parentId: "block:splitchord",
+ defaultKey: "ArrowLeft",
+ handler: () => {
+ handleSplitHorizontal("before");
+ return true;
+ },
+ },
+ {
+ id: "block:splitchordright",
+ parentId: "block:splitchord",
+ defaultKey: "ArrowRight",
+ handler: () => {
+ handleSplitHorizontal("after");
+ return true;
+ },
+ },
+];
+
+function buildKeyMaps(userOverrides: KeybindingEntry[]): void {
+ // 1. Start with default bindings as array entries (key -> handler)
+ const bindings: KeyMapEntry[] = [];
+ const chordBindings: ChordEntry[] = [];
+
+ // Track which action IDs map to which handler (for user overrides)
+ const actionHandlers = new Map();
+ for (const action of defaultActions) {
+ actionHandlers.set(action.id, action.handler);
+ for (const key of action.defaultKeys) {
+ bindings.push({ key, handler: action.handler });
+ }
+ }
+
+ // 2. Build chord bindings from defaults
+ const chordInitiatorAction = defaultActions.find((a) => a.id === "block:splitchord");
+ if (chordInitiatorAction) {
+ const subKeys: KeyMapEntry[] = [];
+ for (const chordDef of defaultChordActions) {
+ if (chordDef.parentId === "block:splitchord") {
+ actionHandlers.set(chordDef.id, chordDef.handler);
+ subKeys.push({ key: chordDef.defaultKey, handler: chordDef.handler });
+ }
+ }
+ for (const key of chordInitiatorAction.defaultKeys) {
+ chordBindings.push({ key, subKeys: [...subKeys] });
}
- return false;
}
- globalKeyMap.set("Cmd:f", activateSearch);
- globalKeyMap.set("Escape", () => {
- if (modalsModel.hasOpenModals()) {
- modalsModel.popModal();
- return true;
+
+ // 3. Apply user overrides — append to array (last wins via reverse iteration)
+ for (const override of userOverrides) {
+ if (!override.command || typeof override.command !== "string") {
+ console.warn("Skipping keybinding entry with missing/invalid command");
+ continue;
}
- if (deactivateSearch()) {
- return true;
+ if (override.key != null && typeof override.key !== "string") {
+ console.warn(`Skipping keybinding entry with invalid key type for command: ${override.command}`);
+ continue;
}
- return false;
- });
- globalKeyMap.set("Cmd:Shift:a", () => {
- const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();
- WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible);
- return true;
- });
- const allKeys = Array.from(globalKeyMap.keys());
- // special case keys, handled by web view
+ // Handle -command unbinding (VSCode convention)
+ if (override.command.startsWith("-")) {
+ const commandId = override.command.substring(1);
+ const action = defaultActions.find((a) => a.id === commandId);
+ if (action) {
+ // Append null-handler entries for all default keys to shadow them
+ for (const key of action.defaultKeys) {
+ bindings.push({ key, handler: null });
+ }
+ }
+ continue;
+ }
+ const commandId = override.command;
+ if (override.key == null) {
+ // null key = unbind all default keys for this command
+ const action = defaultActions.find((a) => a.id === commandId);
+ if (action) {
+ for (const key of action.defaultKeys) {
+ bindings.push({ key, handler: null });
+ }
+ }
+ continue;
+ }
+ const handler = actionHandlers.get(commandId);
+ if (handler) {
+ bindings.push({ key: override.key, handler });
+ } else {
+ console.warn(`Unknown keybinding action: ${commandId}`);
+ }
+ }
+
+ // 4. Assign to globals
+ globalKeyBindings = bindings;
+ globalChordBindings = chordBindings;
+
+ // 5. Re-register with Electron
+ const allKeys = globalKeyBindings.map((e) => e.key);
+ for (const chord of globalChordBindings) {
+ allKeys.push(chord.key);
+ }
+ // Special web view keys
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o");
getApi().registerGlobalWebviewKeys(allKeys);
+}
- const splitBlockKeys = new Map();
- splitBlockKeys.set("ArrowUp", () => {
- handleSplitVertical("before");
- return true;
- });
- splitBlockKeys.set("ArrowDown", () => {
- handleSplitVertical("after");
- return true;
- });
- splitBlockKeys.set("ArrowLeft", () => {
- handleSplitHorizontal("before");
- return true;
- });
- splitBlockKeys.set("ArrowRight", () => {
- handleSplitHorizontal("after");
- return true;
+function registerGlobalKeys() {
+ buildKeyMaps([]);
+}
+
+function initKeybindingsWatcher() {
+ globalStore.sub(atoms.keybindingsAtom, () => {
+ buildKeyMaps(globalStore.get(atoms.keybindingsAtom));
});
- globalChordMap.set("Ctrl:Shift:s", splitBlockKeys);
}
function registerBuilderGlobalKeys() {
- globalKeyMap.set("Cmd:w", () => {
- getApi().closeBuilderWindow();
- return true;
+ globalKeyBindings.push({
+ key: "Cmd:w",
+ handler: () => {
+ getApi().closeBuilderWindow();
+ return true;
+ },
});
- const allKeys = Array.from(globalKeyMap.keys());
+ const allKeys = globalKeyBindings.map((e) => e.key);
getApi().registerGlobalWebviewKeys(allKeys);
}
function getAllGlobalKeyBindings(): string[] {
- const allKeys = Array.from(globalKeyMap.keys());
- return allKeys;
+ return globalKeyBindings.map((e) => e.key);
}
export {
appHandleKeyDown,
+ buildKeyMaps,
disableGlobalKeybindings,
enableGlobalKeybindings,
getSimpleControlShiftAtom,
globalRefocus,
globalRefocusWithTimeout,
+ initKeybindingsWatcher,
registerBuilderGlobalKeys,
registerControlShiftStateUpdateHandler,
registerElectronReinjectKeyHandler,
diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts
index 73703c2e87..14830e37fc 100644
--- a/frontend/app/view/waveconfig/waveconfig-model.ts
+++ b/frontend/app/view/waveconfig/waveconfig-model.ts
@@ -27,6 +27,7 @@ export type ConfigFile = {
validator?: ConfigValidator;
isSecrets?: boolean;
hasJsonView?: boolean;
+ allowArray?: boolean;
visualComponent?: React.ComponentType<{ model: WaveConfigViewModel }>;
};
@@ -64,6 +65,14 @@ function makeConfigFiles(isWindows: boolean): ConfigFile[] {
docsUrl: "https://docs.waveterm.dev/config",
hasJsonView: true,
},
+ {
+ name: "Keybindings",
+ path: "keybindings.json",
+ language: "json",
+ description: "Custom keyboard shortcuts",
+ docsUrl: "https://docs.waveterm.dev/keybindings",
+ allowArray: true,
+ },
{
name: "Connections",
path: "connections.json",
@@ -359,7 +368,12 @@ export class WaveConfigViewModel implements ViewModel {
try {
const parsed = JSON.parse(fileContent);
- if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
+ if (selectedFile.allowArray) {
+ if (!Array.isArray(parsed)) {
+ globalStore.set(this.validationErrorAtom, "JSON must be an array");
+ return;
+ }
+ } else if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
globalStore.set(this.validationErrorAtom, "JSON must be an object, not an array, primitive, or null");
return;
}
diff --git a/frontend/preview/mock/defaultconfig.ts b/frontend/preview/mock/defaultconfig.ts
index 415630b2b6..5bf64f7e75 100644
--- a/frontend/preview/mock/defaultconfig.ts
+++ b/frontend/preview/mock/defaultconfig.ts
@@ -20,5 +20,6 @@ export const DefaultFullConfig: FullConfigType = {
bookmarks: {},
waveai: waveaiJson as unknown as { [key: string]: AIModeConfigType },
backgrounds: backgroundsJson as { [key: string]: BackgroundConfigType },
+ keybindings: "[]",
configerrors: [],
};
diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts
index ea5a8b0b90..e39ab5507b 100644
--- a/frontend/preview/mock/mockwaveenv.ts
+++ b/frontend/preview/mock/mockwaveenv.ts
@@ -170,6 +170,17 @@ function makeMockGlobalAtoms(
fullConfigAtom,
waveaiModeConfigAtom: atom({}) as any,
settingsAtom,
+ keybindingsAtom: atom((get) => {
+ const fullConfig = get(fullConfigAtom);
+ if (!fullConfig?.keybindings) return [];
+ try {
+ const raw = fullConfig.keybindings;
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }),
hasCustomAIPresetsAtom: atom(false),
hasConfigErrors: atom((get) => {
const c = get(fullConfigAtom);
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts
index 9f7cb15ad3..b51f6a0a20 100644
--- a/frontend/types/custom.d.ts
+++ b/frontend/types/custom.d.ts
@@ -16,6 +16,7 @@ declare global {
fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket
waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket
settingsAtom: jotai.Atom; // derrived from fullConfig
+ keybindingsAtom: jotai.Atom; // derived from fullConfig keybindings
hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig
hasConfigErrors: jotai.Atom; // derived from fullConfig
staticTabId: jotai.Atom;
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index 7a60b6877d..ebd5e1d86a 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -1001,6 +1001,7 @@ declare global {
connections: {[key: string]: ConnKeywords};
bookmarks: {[key: string]: WebBookmark};
waveai: {[key: string]: AIModeConfigType};
+ keybindings: string;
configerrors: ConfigError[];
};
diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts
index 867dfcb4e2..bfc58fd632 100644
--- a/frontend/util/keyutil.ts
+++ b/frontend/util/keyutil.ts
@@ -75,32 +75,33 @@ function parseKeyDescription(keyDescription: string): KeyPressDecl {
let rtn = { key: "", mods: {} } as KeyPressDecl;
let keys = keyDescription.replace(/[()]/g, "").split(":");
for (let key of keys) {
- if (key == "Cmd") {
+ const keyLower = key.toLowerCase();
+ if (keyLower == "cmd") {
if (PLATFORM == PlatformMacOS) {
rtn.mods.Meta = true;
} else {
rtn.mods.Alt = true;
}
rtn.mods.Cmd = true;
- } else if (key == "Shift") {
+ } else if (keyLower == "shift") {
rtn.mods.Shift = true;
- } else if (key == "Ctrl") {
+ } else if (keyLower == "ctrl") {
rtn.mods.Ctrl = true;
- } else if (key == "Option") {
+ } else if (keyLower == "option") {
if (PLATFORM == PlatformMacOS) {
rtn.mods.Alt = true;
} else {
rtn.mods.Meta = true;
}
rtn.mods.Option = true;
- } else if (key == "Alt") {
+ } else if (keyLower == "alt") {
if (PLATFORM == PlatformMacOS) {
rtn.mods.Option = true;
} else {
rtn.mods.Cmd = true;
}
rtn.mods.Alt = true;
- } else if (key == "Meta") {
+ } else if (keyLower == "meta") {
if (PLATFORM == PlatformMacOS) {
rtn.mods.Cmd = true;
} else {
diff --git a/frontend/wave.ts b/frontend/wave.ts
index 20ee2ba97a..64bcaa777f 100644
--- a/frontend/wave.ts
+++ b/frontend/wave.ts
@@ -7,6 +7,7 @@ import { loadBadges } from "@/app/store/badge";
import { GlobalModel } from "@/app/store/global-model";
import {
globalRefocus,
+ initKeybindingsWatcher,
registerBuilderGlobalKeys,
registerControlShiftStateUpdateHandler,
registerElectronReinjectKeyHandler,
@@ -188,6 +189,7 @@ async function initWave(initOpts: WaveInitOpts) {
getApi().sendLog("Error in initialization (wave.ts, loading required objects) " + e.message + "\n" + e.stack);
}
registerGlobalKeys();
+ initKeybindingsWatcher();
registerElectronReinjectKeyHandler();
registerControlShiftStateUpdateHandler();
await loadMonaco();
diff --git a/pkg/wconfig/defaultconfig/keybindings.json b/pkg/wconfig/defaultconfig/keybindings.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/pkg/wconfig/defaultconfig/keybindings.json
@@ -0,0 +1 @@
+[]
diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go
index b55cab8cbf..020cc24556 100644
--- a/pkg/wconfig/settingsconfig.go
+++ b/pkg/wconfig/settingsconfig.go
@@ -374,6 +374,7 @@ type FullConfigType struct {
Connections map[string]ConnKeywords `json:"connections"`
Bookmarks map[string]WebBookmark `json:"bookmarks"`
WaveAIModes map[string]AIModeConfigType `json:"waveai"`
+ Keybindings json.RawMessage `json:"keybindings" configfile:"-"`
ConfigErrors []ConfigError `json:"configerrors" configfile:"-"`
}
@@ -665,6 +666,22 @@ func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []C
return mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs
}
+func readKeybindingsConfig() (json.RawMessage, []ConfigError) {
+ configDir := wavebase.GetWaveConfigDir()
+ filePath := filepath.Join(configDir, "keybindings.json")
+ barr, err := os.ReadFile(filePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, []ConfigError{{File: "keybindings.json", Err: fmt.Sprintf("error reading keybindings.json: %v", err)}}
+ }
+ if !json.Valid(barr) {
+ return nil, []ConfigError{{File: "keybindings.json", Err: "invalid JSON in keybindings.json"}}
+ }
+ return json.RawMessage(barr), nil
+}
+
// this function should only be called by the wconfig code.
// in golang code, the best way to get the current config is via the watcher -- wconfig.GetWatcher().GetFullConfig()
func ReadFullConfig() FullConfigType {
@@ -695,6 +712,9 @@ func ReadFullConfig() FullConfigType {
utilfn.ReUnmarshal(fieldPtr, configPart)
}
}
+ keybindings, keybindingErrs := readKeybindingsConfig()
+ fullConfig.Keybindings = keybindings
+ fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, keybindingErrs...)
return fullConfig
}
diff --git a/schema/keybindings.json b/schema/keybindings.json
new file mode 100644
index 0000000000..599fe00485
--- /dev/null
+++ b/schema/keybindings.json
@@ -0,0 +1,53 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/wavetermdev/waveterm/schema/keybindings",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "oneOf": [
+ { "type": "string" },
+ { "type": "null" }
+ ],
+ "description": "Key combination using colon-separated format. Modifiers: Cmd, Ctrl, Shift, Alt, Meta. Special keys: ArrowUp/Down/Left/Right, Home, End, Escape, Enter, Tab, Space, Backspace, Delete. Set to null to unbind."
+ },
+ "command": {
+ "type": "string",
+ "description": "Action ID to bind. Prefix with '-' to unbind all default keys for that action.",
+ "enum": [
+ "tab:new", "tab:close", "tab:prev", "tab:next",
+ "tab:switchto1", "tab:switchto2", "tab:switchto3", "tab:switchto4", "tab:switchto5",
+ "tab:switchto6", "tab:switchto7", "tab:switchto8", "tab:switchto9",
+ "block:new", "block:close", "block:splitright", "block:splitdown",
+ "block:magnify", "block:refocus",
+ "block:navup", "block:navdown", "block:navleft", "block:navright",
+ "block:navcw", "block:navccw",
+ "block:switchto1", "block:switchto2", "block:switchto3", "block:switchto4", "block:switchto5",
+ "block:switchto6", "block:switchto7", "block:switchto8", "block:switchto9", "block:switchtoai",
+ "block:replacewithlauncher",
+ "block:splitchord", "block:splitchordup", "block:splitchorddown", "block:splitchordleft", "block:splitchordright",
+ "app:search", "app:openconnection", "app:toggleaipanel", "app:togglewidgetssidebar", "app:settings",
+ "term:togglemultiinput",
+ "generic:cancel",
+ "-tab:new", "-tab:close", "-tab:prev", "-tab:next",
+ "-tab:switchto1", "-tab:switchto2", "-tab:switchto3", "-tab:switchto4", "-tab:switchto5",
+ "-tab:switchto6", "-tab:switchto7", "-tab:switchto8", "-tab:switchto9",
+ "-block:new", "-block:close", "-block:splitright", "-block:splitdown",
+ "-block:magnify", "-block:refocus",
+ "-block:navup", "-block:navdown", "-block:navleft", "-block:navright",
+ "-block:navcw", "-block:navccw",
+ "-block:switchto1", "-block:switchto2", "-block:switchto3", "-block:switchto4", "-block:switchto5",
+ "-block:switchto6", "-block:switchto7", "-block:switchto8", "-block:switchto9", "-block:switchtoai",
+ "-block:replacewithlauncher",
+ "-block:splitchord", "-block:splitchordup", "-block:splitchorddown", "-block:splitchordleft", "-block:splitchordright",
+ "-app:search", "-app:openconnection", "-app:toggleaipanel", "-app:togglewidgetssidebar", "-app:settings",
+ "-term:togglemultiinput",
+ "-generic:cancel"
+ ]
+ }
+ },
+ "required": ["command"],
+ "additionalProperties": false
+ }
+}