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 + } +}