From e196f4a81b825f3d52702ed06b1b80ef112e8d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Fri, 27 Mar 2026 21:35:33 +0100 Subject: [PATCH 1/7] feat: add custom keybindings support with VSCode-style configuration Refactor keybindings into a declarative action registry with user override support via keybindings.json. Includes JSON schema, config sidebar entry, documentation, and Go backend support for reading keybindings config. --- docs/docs/keybindings.mdx | 69 +- frontend/app/monaco/schemaendpoints.ts | 6 + frontend/app/store/global-atoms.ts | 13 + frontend/app/store/keymodel.ts | 624 +++++++++++------- .../app/view/waveconfig/waveconfig-model.ts | 7 + frontend/preview/mock/defaultconfig.ts | 1 + frontend/preview/mock/mockwaveenv.ts | 11 + frontend/types/custom.d.ts | 1 + frontend/types/gotypes.d.ts | 1 + frontend/wave.ts | 2 + pkg/wconfig/defaultconfig/keybindings.json | 1 + pkg/wconfig/settingsconfig.go | 20 + schema/keybindings.json | 51 ++ 13 files changed, 583 insertions(+), 224 deletions(-) create mode 100644 pkg/wconfig/defaultconfig/keybindings.json create mode 100644 schema/keybindings.json diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index fa8dcae1ba..01121eab10 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -105,7 +105,74 @@ 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 +[ + { "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/Down/Left/Right` | | Navigate between blocks | +| `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 | +| `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..d871692aad 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -33,6 +33,24 @@ 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; +}; + const simpleControlShiftAtom = jotai.atom(false); const globalKeyMap = new Map boolean>(); const globalChordMap = new Map>(); @@ -500,261 +518,419 @@ 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:replaceWithLauncher", + defaultKeys: ["Ctrl:Shift:x"], + handler: () => { + const blockId = getFocusedBlockId(); + if (blockId == null) { + return true; + } + replaceBlock( + blockId, + { + meta: { + view: "launcher", + }, + }, + true + ); 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: "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; + }, + }, + // 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. Build resolved map: actionId -> { keys, handler } + const resolvedActions = new Map(); + for (const action of defaultActions) { + resolvedActions.set(action.id, { keys: [...action.defaultKeys], handler: action.handler }); + } + + // 2. Apply user overrides in order (last wins) + for (const override of userOverrides) { + if (!override.command || typeof override.command !== "string") { + console.warn("Skipping keybinding entry with missing/invalid command"); + continue; + } + if (override.key != null && typeof override.key !== "string") { + console.warn(`Skipping keybinding entry with invalid key type for command: ${override.command}`); + continue; + } + const commandId = override.command.startsWith("-") ? override.command.substring(1) : override.command; + if (override.command.startsWith("-") || override.key == null) { + // Unbind: remove the action + resolvedActions.delete(commandId); + } else { + // Override: replace that action's keys + const existing = resolvedActions.get(commandId); + if (existing) { + existing.keys = [override.key]; + } else { + console.warn(`Unknown keybinding action: ${commandId}`); + } } - return false; } - globalKeyMap.set("Cmd:f", activateSearch); - globalKeyMap.set("Escape", () => { - if (modalsModel.hasOpenModals()) { - modalsModel.popModal(); - return true; + + // 3. Clear and rebuild maps + globalKeyMap.clear(); + globalChordMap.clear(); + + // 4. Find chord initiator keys + const chordInitiatorKeys = new Map(); // parentId -> keys + const chordAction = resolvedActions.get("block:splitChord"); + if (chordAction) { + chordInitiatorKeys.set("block:splitChord", chordAction.keys); + resolvedActions.delete("block:splitChord"); // don't add to globalKeyMap + } + + // 5. Build chord sub-key maps + for (const [parentId, initiatorKeys] of chordInitiatorKeys) { + const subKeyMap = new Map(); + for (const chordDef of defaultChordActions) { + if (chordDef.parentId === parentId) { + // Check if user overrode this chord sub-action + let subKey: string | null = chordDef.defaultKey; + for (const override of userOverrides) { + const cmdId = override.command.startsWith("-") + ? override.command.substring(1) + : override.command; + if (cmdId === chordDef.id) { + if (override.command.startsWith("-") || override.key == null) { + subKey = null; // unbind + } else { + subKey = override.key; + } + } + } + if (subKey != null) { + subKeyMap.set(subKey, chordDef.handler); + } + } } - if (deactivateSearch()) { - return true; + if (subKeyMap.size > 0) { + for (const key of initiatorKeys) { + globalChordMap.set(key, subKeyMap); + } } - return false; - }); - globalKeyMap.set("Cmd:Shift:a", () => { - const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); - WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); - return true; - }); + } + + // 6. Populate globalKeyMap from resolved simple actions + for (const [, actionData] of resolvedActions) { + for (const key of actionData.keys) { + globalKeyMap.set(key, actionData.handler); + } + } + + // 7. Re-register with Electron const allKeys = Array.from(globalKeyMap.keys()); - // special case keys, handled by web view + for (const keys of chordInitiatorKeys.values()) { + allKeys.push(...keys); + } + // 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() { @@ -773,11 +949,13 @@ function getAllGlobalKeyBindings(): string[] { 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..afd680da93 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -64,6 +64,13 @@ 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", + }, { name: "Connections", path: "connections.json", 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/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..0eb403f48d --- /dev/null +++ b/schema/keybindings.json @@ -0,0 +1,51 @@ +{ + "$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. Prefix with - to unbind a default keybinding.", + "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: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", + "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: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", + "-term:toggleMultiInput", + "-generic:cancel" + ] + } + }, + "required": ["command"], + "additionalProperties": false + } +} From e2c04f378bffd2bb9acf7b8fee89dbbb32f30751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Sat, 28 Mar 2026 00:36:22 +0100 Subject: [PATCH 2/7] refactor: replace Map-based keybinding storage with arrays for VSCode-style last-wins resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit globalKeyMap and globalChordMap were Maps but checkKeyMap already iterated linearly over all entries (no O(1) lookup benefit). Switching to arrays with reverse iteration gives natural "last entry wins" semantics — user overrides appended after defaults automatically shadow them without explicit merge logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/app/store/keymodel.ts | 175 +++++++++++++++------------------ 1 file changed, 78 insertions(+), 97 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index d871692aad..89e906618c 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -51,9 +51,12 @@ type ChordActionDef = { handler: KeyHandler; }; +type KeyMapEntry = { key: string; handler: T }; +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) @@ -420,12 +423,12 @@ 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 +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)) { + return [entry.key, entry.handler]; } } return [null, null]; @@ -442,25 +445,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) { @@ -832,13 +838,35 @@ const defaultChordActions: ChordActionDef[] = [ ]; function buildKeyMaps(userOverrides: KeybindingEntry[]): void { - // 1. Build resolved map: actionId -> { keys, handler } - const resolvedActions = new Map(); + // 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) { - resolvedActions.set(action.id, { keys: [...action.defaultKeys], handler: action.handler }); + actionHandlers.set(action.id, action.handler); + for (const key of action.defaultKeys) { + bindings.push({ key, handler: action.handler }); + } } - // 2. Apply user overrides in order (last wins) + // 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] }); + } + } + + // 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"); @@ -848,75 +876,26 @@ function buildKeyMaps(userOverrides: KeybindingEntry[]): void { console.warn(`Skipping keybinding entry with invalid key type for command: ${override.command}`); continue; } - const commandId = override.command.startsWith("-") ? override.command.substring(1) : override.command; - if (override.command.startsWith("-") || override.key == null) { - // Unbind: remove the action - resolvedActions.delete(commandId); - } else { - // Override: replace that action's keys - const existing = resolvedActions.get(commandId); - if (existing) { - existing.keys = [override.key]; - } else { - console.warn(`Unknown keybinding action: ${commandId}`); - } - } - } - - // 3. Clear and rebuild maps - globalKeyMap.clear(); - globalChordMap.clear(); - - // 4. Find chord initiator keys - const chordInitiatorKeys = new Map(); // parentId -> keys - const chordAction = resolvedActions.get("block:splitChord"); - if (chordAction) { - chordInitiatorKeys.set("block:splitChord", chordAction.keys); - resolvedActions.delete("block:splitChord"); // don't add to globalKeyMap - } - - // 5. Build chord sub-key maps - for (const [parentId, initiatorKeys] of chordInitiatorKeys) { - const subKeyMap = new Map(); - for (const chordDef of defaultChordActions) { - if (chordDef.parentId === parentId) { - // Check if user overrode this chord sub-action - let subKey: string | null = chordDef.defaultKey; - for (const override of userOverrides) { - const cmdId = override.command.startsWith("-") - ? override.command.substring(1) - : override.command; - if (cmdId === chordDef.id) { - if (override.command.startsWith("-") || override.key == null) { - subKey = null; // unbind - } else { - subKey = override.key; - } - } - } - if (subKey != null) { - subKeyMap.set(subKey, chordDef.handler); - } - } + const commandId = override.command; + if (override.key == null) { + continue; // null key = no binding, handled by reverse iteration skipping } - if (subKeyMap.size > 0) { - for (const key of initiatorKeys) { - globalChordMap.set(key, subKeyMap); - } + const handler = actionHandlers.get(commandId); + if (handler) { + bindings.push({ key: override.key, handler }); + } else { + console.warn(`Unknown keybinding action: ${commandId}`); } } - // 6. Populate globalKeyMap from resolved simple actions - for (const [, actionData] of resolvedActions) { - for (const key of actionData.keys) { - globalKeyMap.set(key, actionData.handler); - } - } + // 4. Assign to globals + globalKeyBindings = bindings; + globalChordBindings = chordBindings; - // 7. Re-register with Electron - const allKeys = Array.from(globalKeyMap.keys()); - for (const keys of chordInitiatorKeys.values()) { - allKeys.push(...keys); + // 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"); @@ -934,17 +913,19 @@ function initKeybindingsWatcher() { } 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 { From c3c6df1334e4dbbb64fd0aad3a16a5e33b58d664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Sat, 28 Mar 2026 00:50:44 +0100 Subject: [PATCH 3/7] feat: add block focus cycling (CW/CCW) and lowercase all keybinding IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add block:focusnext (Ctrl+Shift+]) and block:focusprev (Ctrl+Shift+[) to cycle focus through blocks in leaf order - Lowercase all keybinding command IDs to match settings.json conventions (e.g. block:splitRight → block:splitright, app:toggleAIPanel → app:toggleaipanel) - Update schema and docs to reflect both changes Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/keybindings.mdx | 34 ++++++++------ frontend/app/store/keymodel.ts | 84 ++++++++++++++++++++++++---------- schema/keybindings.json | 38 ++++++--------- 3 files changed, 91 insertions(+), 65 deletions(-) diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 01121eab10..12908cc08c 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 | @@ -131,15 +133,15 @@ Key combinations use colon-separated format: **Disable a keybinding:** Remove close block: ```json [ - { "key": null, "command": "-block:close" } + { "key": null, "command": "block:close" } ] ``` **Swap two keys:** ```json [ - { "key": "Cmd:d", "command": "block:splitDown" }, - { "key": "Cmd:Shift:d", "command": "block:splitRight" } + { "key": "Cmd:d", "command": "block:splitdown" }, + { "key": "Cmd:Shift:d", "command": "block:splitright" } ] ``` @@ -151,24 +153,26 @@ Key combinations use colon-separated format: | `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 | +| `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:splitright` | | Split right | +| `block:splitdown` | | Split down | | `block:magnify` | | Magnify/unmagnify block | | `block:refocus` | | Refocus the current block | -| `block:navUp/Down/Left/Right` | | Navigate between blocks | -| `block:switchTo1`–`block:switchTo9` | | Switch to block N | -| `block:switchToAI` | | Focus WaveAI input | -| `block:replaceWithLauncher` | | Replace block with launcher | +| `block:navup/navdown/navleft/navright` | | Navigate between blocks | +| `block:focusnext` | | Cycle block focus forward (CW) | +| `block:focusprev` | | 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 | -| `term:toggleMultiInput` | | Toggle terminal multi-input | +| `app:openconnection` | | Open connection switcher | +| `app:toggleaipanel` | | Toggle WaveAI panel | +| `app:togglewidgetssidebar` | | Toggle widgets sidebar | +| `term:togglemultiinput` | | Toggle terminal multi-input | | `generic:cancel` | | Close modals/search | -| `block:splitChord` | | Initiate split chord | +| `block:splitchord` | | Initiate split chord | Changes take effect immediately — no restart required. diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 89e906618c..0b7466e884 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -260,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(); @@ -591,7 +609,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "block:splitRight", + id: "block:splitright", defaultKeys: ["Cmd:d"], handler: () => { handleSplitHorizontal("after"); @@ -599,7 +617,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "block:splitDown", + id: "block:splitdown", defaultKeys: ["Shift:Cmd:d"], handler: () => { handleSplitVertical("after"); @@ -651,27 +669,43 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "block:navUp", + id: "block:navup", defaultKeys: ["Ctrl:Shift:ArrowUp", "Ctrl:Shift:k"], handler: makeBlockNavHandler(NavigateDirection.Up), }, { - id: "block:navDown", + id: "block:navdown", defaultKeys: ["Ctrl:Shift:ArrowDown", "Ctrl:Shift:j"], handler: makeBlockNavHandler(NavigateDirection.Down), }, { - id: "block:navLeft", + id: "block:navleft", defaultKeys: ["Ctrl:Shift:ArrowLeft", "Ctrl:Shift:h"], handler: makeBlockNavHandler(NavigateDirection.Left), }, { - id: "block:navRight", + id: "block:navright", defaultKeys: ["Ctrl:Shift:ArrowRight", "Ctrl:Shift:l"], handler: makeBlockNavHandler(NavigateDirection.Right), }, { - id: "block:replaceWithLauncher", + id: "block:focusnext", + defaultKeys: ["Ctrl:Shift:]"], + handler: () => { + cycleBlockFocus(1); + return true; + }, + }, + { + id: "block:focusprev", + defaultKeys: ["Ctrl:Shift:["], + handler: () => { + cycleBlockFocus(-1); + return true; + }, + }, + { + id: "block:replacewithlauncher", defaultKeys: ["Ctrl:Shift:x"], handler: () => { const blockId = getFocusedBlockId(); @@ -691,7 +725,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "app:openConnection", + id: "app:openconnection", defaultKeys: ["Cmd:g"], handler: () => { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); @@ -704,7 +738,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "term:toggleMultiInput", + id: "term:togglemultiinput", defaultKeys: ["Ctrl:Shift:i"], handler: () => { const tabModel = getActiveTabModel(); @@ -740,7 +774,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "app:toggleAIPanel", + id: "app:toggleaipanel", defaultKeys: ["Cmd:Shift:a"], handler: () => { const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); @@ -749,7 +783,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "app:toggleWidgetsSidebar", + id: "app:togglewidgetssidebar", defaultKeys: ["Cmd:b"], handler: () => { const current = WorkspaceLayoutModel.getInstance().getWidgetsSidebarVisible(); @@ -762,7 +796,7 @@ const defaultActions: ActionDef[] = [ const idx = i + 1; return [ { - id: `tab:switchTo${idx}`, + id: `tab:switchto${idx}`, defaultKeys: [`Cmd:${idx}`], handler: () => { switchTabAbs(idx); @@ -770,7 +804,7 @@ const defaultActions: ActionDef[] = [ }, } as ActionDef, { - id: `block:switchTo${idx}`, + id: `block:switchto${idx}`, defaultKeys: [`Ctrl:Shift:c{Digit${idx}}`, `Ctrl:Shift:c{Numpad${idx}}`], handler: () => { switchBlockByBlockNum(idx); @@ -781,7 +815,7 @@ const defaultActions: ActionDef[] = [ }).flat(), // AI focus (block 0) — platform-dependent keys { - id: "block:switchToAI", + id: "block:switchtoai", defaultKeys: isWindows() ? ["Alt:c{Digit0}", "Alt:c{Numpad0}"] : ["Ctrl:Shift:c{Digit0}", "Ctrl:Shift:c{Numpad0}"], @@ -792,7 +826,7 @@ const defaultActions: ActionDef[] = [ }, // Chord initiator for block splitting { - id: "block:splitChord", + id: "block:splitchord", defaultKeys: ["Ctrl:Shift:s"], handler: () => true, }, @@ -800,8 +834,8 @@ const defaultActions: ActionDef[] = [ const defaultChordActions: ChordActionDef[] = [ { - id: "block:splitChordUp", - parentId: "block:splitChord", + id: "block:splitchordup", + parentId: "block:splitchord", defaultKey: "ArrowUp", handler: () => { handleSplitVertical("before"); @@ -809,8 +843,8 @@ const defaultChordActions: ChordActionDef[] = [ }, }, { - id: "block:splitChordDown", - parentId: "block:splitChord", + id: "block:splitchorddown", + parentId: "block:splitchord", defaultKey: "ArrowDown", handler: () => { handleSplitVertical("after"); @@ -818,8 +852,8 @@ const defaultChordActions: ChordActionDef[] = [ }, }, { - id: "block:splitChordLeft", - parentId: "block:splitChord", + id: "block:splitchordleft", + parentId: "block:splitchord", defaultKey: "ArrowLeft", handler: () => { handleSplitHorizontal("before"); @@ -827,8 +861,8 @@ const defaultChordActions: ChordActionDef[] = [ }, }, { - id: "block:splitChordRight", - parentId: "block:splitChord", + id: "block:splitchordright", + parentId: "block:splitchord", defaultKey: "ArrowRight", handler: () => { handleSplitHorizontal("after"); @@ -852,11 +886,11 @@ function buildKeyMaps(userOverrides: KeybindingEntry[]): void { } // 2. Build chord bindings from defaults - const chordInitiatorAction = defaultActions.find((a) => a.id === "block:splitChord"); + const chordInitiatorAction = defaultActions.find((a) => a.id === "block:splitchord"); if (chordInitiatorAction) { const subKeys: KeyMapEntry[] = []; for (const chordDef of defaultChordActions) { - if (chordDef.parentId === "block:splitChord") { + if (chordDef.parentId === "block:splitchord") { actionHandlers.set(chordDef.id, chordDef.handler); subKeys.push({ key: chordDef.defaultKey, handler: chordDef.handler }); } diff --git a/schema/keybindings.json b/schema/keybindings.json index 0eb403f48d..ed7761ed7d 100644 --- a/schema/keybindings.json +++ b/schema/keybindings.json @@ -14,34 +14,22 @@ }, "command": { "type": "string", - "description": "Action ID. Prefix with - to unbind a default keybinding.", + "description": "Action ID to bind.", "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", + "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: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", - "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: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", - "-term:toggleMultiInput", - "-generic:cancel" + "block:navup", "block:navdown", "block:navleft", "block:navright", + "block:focusnext", "block:focusprev", + "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", + "term:togglemultiinput", + "generic:cancel" ] } }, From 09902742f12d9dfd3af248ef9c6ed1a947fc329d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Sat, 28 Mar 2026 00:58:24 +0100 Subject: [PATCH 4/7] feat: implement VSCode-style -command unbinding for keybindings Both unbinding mechanisms now work: - "-block:close" prefix removes all default keys for that command - { key: null, command: "block:close" } does the same thing Both append null-handler entries that shadow defaults via reverse iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/keybindings.mdx | 7 +++++++ frontend/app/store/keymodel.ts | 27 +++++++++++++++++++++++++-- schema/keybindings.json | 18 ++++++++++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 12908cc08c..6da4d16865 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -132,6 +132,13 @@ Key combinations use colon-separated format: **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" } ] diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 0b7466e884..d06aa9cb02 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -51,7 +51,7 @@ type ChordActionDef = { handler: KeyHandler; }; -type KeyMapEntry = { key: string; handler: T }; +type KeyMapEntry = { key: string; handler: T | null }; type ChordEntry = { key: string; subKeys: KeyMapEntry[] }; const simpleControlShiftAtom = jotai.atom(false); @@ -442,10 +442,14 @@ async function handleSplitVertical(position: "before" | "after") { let lastHandledEvent: KeyboardEvent | null = null; // 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]; } } @@ -910,9 +914,28 @@ function buildKeyMaps(userOverrides: KeybindingEntry[]): void { console.warn(`Skipping keybinding entry with invalid key type for command: ${override.command}`); continue; } + // 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) { - continue; // null key = no binding, handled by reverse iteration skipping + // 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) { diff --git a/schema/keybindings.json b/schema/keybindings.json index ed7761ed7d..03c12e618d 100644 --- a/schema/keybindings.json +++ b/schema/keybindings.json @@ -14,7 +14,7 @@ }, "command": { "type": "string", - "description": "Action ID to bind.", + "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", @@ -29,7 +29,21 @@ "block:splitchord", "block:splitchordup", "block:splitchorddown", "block:splitchordleft", "block:splitchordright", "app:search", "app:openconnection", "app:toggleaipanel", "app:togglewidgetssidebar", "term:togglemultiinput", - "generic:cancel" + "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:focusnext", "-block:focusprev", + "-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", + "-term:togglemultiinput", + "-generic:cancel" ] } }, From 799855ec3b2c7614ef8743c88bd21204e5bbae7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Sat, 28 Mar 2026 10:39:42 +0100 Subject: [PATCH 5/7] rename block:focusnext/focusprev to block:navcw/navccw Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/keybindings.mdx | 4 ++-- frontend/app/store/keymodel.ts | 4 ++-- schema/keybindings.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 6da4d16865..673b33e1c9 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -168,8 +168,8 @@ You can also set `key` to `null` to unbind: | `block:magnify` | | Magnify/unmagnify block | | `block:refocus` | | Refocus the current block | | `block:navup/navdown/navleft/navright` | | Navigate between blocks | -| `block:focusnext` | | Cycle block focus forward (CW) | -| `block:focusprev` | | Cycle block focus backward (CCW) | +| `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 | diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index d06aa9cb02..758b2f1dde 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -693,7 +693,7 @@ const defaultActions: ActionDef[] = [ handler: makeBlockNavHandler(NavigateDirection.Right), }, { - id: "block:focusnext", + id: "block:navcw", defaultKeys: ["Ctrl:Shift:]"], handler: () => { cycleBlockFocus(1); @@ -701,7 +701,7 @@ const defaultActions: ActionDef[] = [ }, }, { - id: "block:focusprev", + id: "block:navccw", defaultKeys: ["Ctrl:Shift:["], handler: () => { cycleBlockFocus(-1); diff --git a/schema/keybindings.json b/schema/keybindings.json index 03c12e618d..dc1782d8b6 100644 --- a/schema/keybindings.json +++ b/schema/keybindings.json @@ -22,7 +22,7 @@ "block:new", "block:close", "block:splitright", "block:splitdown", "block:magnify", "block:refocus", "block:navup", "block:navdown", "block:navleft", "block:navright", - "block:focusnext", "block:focusprev", + "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", @@ -36,7 +36,7 @@ "-block:new", "-block:close", "-block:splitright", "-block:splitdown", "-block:magnify", "-block:refocus", "-block:navup", "-block:navdown", "-block:navleft", "-block:navright", - "-block:focusnext", "-block:focusprev", + "-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", From d11133f229169720eb77693acb582aa863c78bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Sat, 28 Mar 2026 11:31:54 +0100 Subject: [PATCH 6/7] feat: add Cmd+, settings shortcut, fix keybindings.json array validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add app:settings keybinding (Cmd+,) to open settings block - Fix config editor rejecting keybindings.json save because it requires JSON objects — keybindings.json is an array by design. Added allowArray flag to ConfigFile type. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/keybindings.mdx | 1 + frontend/app/store/keymodel.ts | 10 ++++++++++ frontend/app/view/waveconfig/waveconfig-model.ts | 9 ++++++++- schema/keybindings.json | 4 ++-- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 673b33e1c9..70bda37182 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -177,6 +177,7 @@ You can also set `key` to `null` to unbind: | `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 | diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 758b2f1dde..b3b5c11b8a 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -795,6 +795,16 @@ const defaultActions: ActionDef[] = [ 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; diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index afd680da93..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 }>; }; @@ -70,6 +71,7 @@ function makeConfigFiles(isWindows: boolean): ConfigFile[] { language: "json", description: "Custom keyboard shortcuts", docsUrl: "https://docs.waveterm.dev/keybindings", + allowArray: true, }, { name: "Connections", @@ -366,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/schema/keybindings.json b/schema/keybindings.json index dc1782d8b6..599fe00485 100644 --- a/schema/keybindings.json +++ b/schema/keybindings.json @@ -27,7 +27,7 @@ "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:search", "app:openconnection", "app:toggleaipanel", "app:togglewidgetssidebar", "app:settings", "term:togglemultiinput", "generic:cancel", "-tab:new", "-tab:close", "-tab:prev", "-tab:next", @@ -41,7 +41,7 @@ "-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:search", "-app:openconnection", "-app:toggleaipanel", "-app:togglewidgetssidebar", "-app:settings", "-term:togglemultiinput", "-generic:cancel" ] From e70ddfc900f2fa3e1b93eea734b5938f3249b166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Sat, 28 Mar 2026 11:37:56 +0100 Subject: [PATCH 7/7] fix: make keybinding modifier parsing case-insensitive Users can now write "cmd:[" instead of "Cmd:[" in keybindings.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/util/keyutil.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 {