feat: add custom keybindings support with VSCode-style configuration#3142
feat: add custom keybindings support with VSCode-style configuration#3142mswiszcz wants to merge 7 commits intowavetermdev:mainfrom
Conversation
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.
WalkthroughThis pull request introduces a comprehensive keybindings customization system for Wave. The implementation spans the entire stack: documentation defines the user-facing customization process via Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…-style last-wins resolution 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Users can now write "cmd:[" instead of "Cmd:[" in keybindings.json. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| let keys = keyDescription.replace(/[()]/g, "").split(":"); | ||
| for (let key of keys) { | ||
| if (key == "Cmd") { | ||
| const keyLower = key.toLowerCase(); |
There was a problem hiding this comment.
WARNING: Potential case sensitivity issue - the original code used case-sensitive comparison (key == "Cmd"), which would accept uppercase modifiers like CMD, CTRL, SHIFT. The change to lowercase (keyLower == "cmd") now only accepts lowercase. If users try to use Cmd or CTRL in their keybindings.json, it won't work. Consider whether this is intentional or if you should preserve case-insensitive handling.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge OverviewThe PR implements custom keybindings support with VSCode-style configuration. It adds:
Files Reviewed (18 files)
NoteThe PR is marked as a draft and may need manual verification before merge. Review conducted in READ-ONLY mode - advisory only Reviewed by minimax-m2.5-20260211 · 1,020,825 tokens |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/docs/keybindings.mdx`:
- Around line 124-130: Explain that keybinding overrides are additive by default
and that adding { "key": "Cmd:Shift:t", "command": "tab:new" } does not remove
the existing Cmd:t binding; instruct to either explicitly unbind the default by
adding the negative form "-tab:new" or by using the null-key form { "key": null,
"command": "tab:new" } when showing how to “change” the shortcut, and reference
the example command "tab:new" and the keys "Cmd:t" and "Cmd:Shift:t" so the
author updates the example and description accordingly.
- Around line 118-123: Update the modifiers list in the "Key combinations use
colon-separated format" section: change the description for `Cmd` so it maps to
macOS Command only (not “Windows-Linux Meta”), document `Alt` as macOS Option /
Windows-Linux Alt if desired, and explicitly state `Meta` represents the
Windows/Super (Windows key) on non-macOS. Edit the modifiers bullet that
currently reads "`Cmd` (macOS Command / Windows-Linux Meta), `Ctrl`, `Shift`,
`Alt` (macOS Option), `Meta`" to reflect these corrected mappings so the parser
behavior (Cmd→macOS Command, Alt→Option/Alt, Meta→Windows/Super) matches the
implementation.
In `@frontend/app/store/keymodel.ts`:
- Around line 903-956: User overrides only modify bindings while chordBindings
remain unchanged, so rebinding or unbinding chord initiators/subcommands (e.g.
"block:splitchord", "block:splitchordup") is ineffective; update the logic that
processes userOverrides to also mutate chordBindings and the subKeys inside
them. Specifically, when processing an override whose commandId corresponds to a
chord initiator (look up via defaultActions for id "block:splitchord") update or
remove entries in chordBindings for the old/new key (add {key, subKeys} for
rebinds; add null-handler shadow entries for unbinds), and when an override
targets a chord subcommand (IDs from defaultChordActions) update the matching
subKeys in any chordBindings (replace its defaultKey or set handler to null)
instead of only pushing to bindings; use the existing actionHandlers,
defaultChordActions, chordBindings and bindings variables so appHandleKeyDown()
and globalChordBindings reflect user overrides.
- Around line 549-577: Both activateSearch and deactivateSearch assume
getFocusedBlockInStaticTab() and getBlockComponentModel(...) always return a
valid bcm; add a guard that returns false (no-op) when no focused block or bcm
is undefined. Specifically, in activateSearch and deactivateSearch, call
getFocusedBlockInStaticTab() then getBlockComponentModel(...) and if either
result is falsy (or bcm.viewModel is missing) immediately return false; only
proceed to access bcm.viewModel.searchAtoms and globalStore when bcm and
bcm.viewModel are present.
- Around line 592-605: The Shift+Cmd and Ctrl+Shift bracket shortcuts are broken
because checkKeyPressed() matches characters not shifted punctuation; update the
defaultKeys for the tab navigation bindings (id "tab:next" and "tab:prev",
handlers calling switchTab) to use code-based descriptors that reference
physical keys (use patterns like c{BracketRight} and c{BracketLeft} with your
modifier prefixes, e.g. "Shift:Cmd:c{BracketRight}" / "Shift:Cmd:c{BracketLeft}"
and add the Ctrl:Shift variants similarly) so checkKeyPressed() can match
event.code; apply the same replacement for the other occurrences noted around
the 696-705 block.
In `@frontend/app/view/waveconfig/waveconfig-model.ts`:
- Around line 68-75: The "Keybindings" config entry currently seeds an empty
JSON file with an object, which conflicts with loadFile()'s JSON initialization
({}), causing the "JSON must be an array" check to fail; update the Keybindings
entry (the object with name "Keybindings" in waveconfig-model.ts) to seed an
empty array instead of an empty object (use [] as the initial content) so
new/empty keybindings.json passes the array validation when save/load run; keep
the rest of the entry (path, language, allowArray, etc.) unchanged and no
changes to loadFile() are required.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 538df4d4-91f6-40ab-830a-ae07b7e9c2b0
📒 Files selected for processing (14)
docs/docs/keybindings.mdxfrontend/app/monaco/schemaendpoints.tsfrontend/app/store/global-atoms.tsfrontend/app/store/keymodel.tsfrontend/app/view/waveconfig/waveconfig-model.tsfrontend/preview/mock/defaultconfig.tsfrontend/preview/mock/mockwaveenv.tsfrontend/types/custom.d.tsfrontend/types/gotypes.d.tsfrontend/util/keyutil.tsfrontend/wave.tspkg/wconfig/defaultconfig/keybindings.jsonpkg/wconfig/settingsconfig.goschema/keybindings.json
| 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`) | ||
|
|
There was a problem hiding this comment.
Correct the Windows/Linux modifier mapping here.
This says Cmd means “Windows-Linux Meta”, but the parser maps Cmd to the Alt key on non-macOS and uses Meta for the Windows/Super key. As written, this will send Windows/Linux users to the wrong modifier.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/docs/keybindings.mdx` around lines 118 - 123, Update the modifiers list
in the "Key combinations use colon-separated format" section: change the
description for `Cmd` so it maps to macOS Command only (not “Windows-Linux
Meta”), document `Alt` as macOS Option / Windows-Linux Alt if desired, and
explicitly state `Meta` represents the Windows/Super (Windows key) on non-macOS.
Edit the modifiers bullet that currently reads "`Cmd` (macOS Command /
Windows-Linux Meta), `Ctrl`, `Shift`, `Alt` (macOS Option), `Meta`" to reflect
these corrected mappings so the parser behavior (Cmd→macOS Command,
Alt→Option/Alt, Meta→Windows/Super) matches the implementation.
| ### Examples | ||
|
|
||
| **Rebind a key:** Change "new tab" from <Kbd k="Cmd:t"/> to <Kbd k="Cmd:Shift:t"/>: | ||
| ```json | ||
| [ | ||
| { "key": "Cmd:Shift:t", "command": "tab:new" } | ||
| ] |
There was a problem hiding this comment.
Document this as additive unless the default is explicitly unbound.
This example adds Cmd:Shift:t, but it does not remove Cmd:t. The current override logic only drops the default when the user also adds -tab:new or { "key": null, "command": "tab:new" }, so the example does not actually “change” the shortcut.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/docs/keybindings.mdx` around lines 124 - 130, Explain that keybinding
overrides are additive by default and that adding { "key": "Cmd:Shift:t",
"command": "tab:new" } does not remove the existing Cmd:t binding; instruct to
either explicitly unbind the default by adding the negative form "-tab:new" or
by using the null-key form { "key": null, "command": "tab:new" } when showing
how to “change” the shortcut, and reference the example command "tab:new" and
the keys "Cmd:t" and "Cmd:Shift:t" so the author updates the example and
description accordingly.
| 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; | ||
| } |
There was a problem hiding this comment.
Guard the search shortcuts when no block is focused.
getFocusedBlockInStaticTab() can be empty, and in that state getBlockComponentModel(...) makes bcm.viewModel throw on Cmd+F or Escape. These handlers should turn into a no-op instead of crashing.
Suggested fix
function activateSearch(event: WaveKeyboardEvent): boolean {
- const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
+ const blockId = getFocusedBlockInStaticTab();
+ if (blockId == null) {
+ return false;
+ }
+ const bcm = getBlockComponentModel(blockId);
+ if (bcm?.viewModel == null) {
+ return false;
+ }
// Ctrl+f is reserved in most shells
if (event.control && bcm.viewModel.viewType == "term") {
return false;
@@
function deactivateSearch(): boolean {
- const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
- if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
+ const blockId = getFocusedBlockInStaticTab();
+ if (blockId == null) {
+ return false;
+ }
+ const bcm = getBlockComponentModel(blockId);
+ if (bcm?.viewModel?.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);
return true;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 activateSearch(event: WaveKeyboardEvent): boolean { | |
| const blockId = getFocusedBlockInStaticTab(); | |
| if (blockId == null) { | |
| return false; | |
| } | |
| const bcm = getBlockComponentModel(blockId); | |
| if (bcm?.viewModel == null) { | |
| return false; | |
| } | |
| // 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); | |
| } | |
| return true; | |
| } | |
| return false; | |
| } | |
| function deactivateSearch(): boolean { | |
| const blockId = getFocusedBlockInStaticTab(); | |
| if (blockId == null) { | |
| return false; | |
| } | |
| const bcm = getBlockComponentModel(blockId); | |
| if (bcm?.viewModel?.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { | |
| globalStore.set(bcm.viewModel.searchAtoms.isOpen, false); | |
| return true; | |
| } | |
| return false; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/store/keymodel.ts` around lines 549 - 577, Both activateSearch
and deactivateSearch assume getFocusedBlockInStaticTab() and
getBlockComponentModel(...) always return a valid bcm; add a guard that returns
false (no-op) when no focused block or bcm is undefined. Specifically, in
activateSearch and deactivateSearch, call getFocusedBlockInStaticTab() then
getBlockComponentModel(...) and if either result is falsy (or bcm.viewModel is
missing) immediately return false; only proceed to access
bcm.viewModel.searchAtoms and globalStore when bcm and bcm.viewModel are
present.
| 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) { | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In the DOM KeyboardEvent API, what values does event.keyreport forShift+[, Shift+], Cmd+Shift+[, and Ctrl+Shift+] on a standard US keyboard layout?
💡 Result:
On a standard US keyboard layout, the DOM KeyboardEvent.key values are: - Shift + [: "{" - Shift + ]: "}" - Cmd + Shift + [: "{" - Ctrl + Shift + ]: "}" These values represent the printable Unicode characters produced by pressing the respective bracket keys with the specified modifiers. The event.key property returns the character value taking into account the active modifiers like Shift (which applies to all cases here), as well as the keyboard layout. Cmd (Meta on Mac) and Ctrl do not alter the character produced by the bracket key with Shift, so key remains the shifted character. This is confirmed by the UI Events specification and MDN documentation, which state that for keys with printed representations, key is the Unicode character string generated, considering modifiers and locale/layout (US produces { and } for those combinations). event.code would be "BracketLeft" or "BracketRight" regardless of modifiers, but the question asks for event.key. Modifier keys themselves report "Shift", "Meta", "Control", etc.
Citations:
- 1: https://developer.mozilla.org/en-US/docs/web/api/ui_events/keyboard_event_key_values
- 2: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
- 3: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
- 4: https://www.w3.org/TR/uievents-key/
- 5: https://w3.org/TR/uievents-key
🏁 Script executed:
# First, let's check the actual code at the lines mentioned
head -n 610 frontend/app/store/keymodel.ts | tail -n 30Repository: wavetermdev/waveterm
Length of output: 786
🏁 Script executed:
# Also check lines 696-705
sed -n '690,710p' frontend/app/store/keymodel.tsRepository: wavetermdev/waveterm
Length of output: 579
🏁 Script executed:
# Now let's look at the checkKeyPressed implementation
cat frontend/util/keyutil.tsRepository: wavetermdev/waveterm
Length of output: 10264
🏁 Script executed:
# Search for c{BracketLeft} or c{BracketRight} usage in the codebase
rg 'c\{Bracket' frontend/Repository: wavetermdev/waveterm
Length of output: 46
Use code-based descriptors for shifted bracket shortcuts, as these bindings are currently broken.
The shifted bracket keybindings (Shift:Cmd:], Shift:Cmd:[, Ctrl:Shift:], Ctrl:Shift:[) will not trigger because checkKeyPressed() does not handle shifted punctuation. When Shift is held, the browser reports event.key = "}" for ] and event.key = "{" for [, but the binding descriptor uses the unshifted characters. The uppercase A-Z special case in checkKeyPressed() only normalizes letters, not punctuation.
Use code-based descriptors instead:
- defaultKeys: ["Cmd:]", "Shift:Cmd:]"],
+ defaultKeys: ["Cmd:]", "Shift:Cmd:c{BracketRight}"],
- defaultKeys: ["Cmd:[", "Shift:Cmd:["],
+ defaultKeys: ["Cmd:[", "Shift:Cmd:c{BracketLeft}"],
- defaultKeys: ["Ctrl:Shift:]"],
+ defaultKeys: ["Ctrl:Shift:c{BracketRight}"],
- defaultKeys: ["Ctrl:Shift:["],
+ defaultKeys: ["Ctrl:Shift:c{BracketLeft}"],The c{BracketLeft} and c{BracketRight} patterns use event.code, which reports the physical key position regardless of modifiers.
Also applies to: 696-705
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/store/keymodel.ts` around lines 592 - 605, The Shift+Cmd and
Ctrl+Shift bracket shortcuts are broken because checkKeyPressed() matches
characters not shifted punctuation; update the defaultKeys for the tab
navigation bindings (id "tab:next" and "tab:prev", handlers calling switchTab)
to use code-based descriptors that reference physical keys (use patterns like
c{BracketRight} and c{BracketLeft} with your modifier prefixes, e.g.
"Shift:Cmd:c{BracketRight}" / "Shift:Cmd:c{BracketLeft}" and add the Ctrl:Shift
variants similarly) so checkKeyPressed() can match event.code; apply the same
replacement for the other occurrences noted around the 696-705 block.
| 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}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Chord commands are not actually remappable or unbindable here.
User overrides only mutate bindings; chordBindings stay at their defaults. That makes block:splitchord rebinding a no-op flat handler instead of entering chord mode, and -block:splitchord / -block:splitchordup cannot disable the default split chord because appHandleKeyDown() still consults globalChordBindings first.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/store/keymodel.ts` around lines 903 - 956, User overrides only
modify bindings while chordBindings remain unchanged, so rebinding or unbinding
chord initiators/subcommands (e.g. "block:splitchord", "block:splitchordup") is
ineffective; update the logic that processes userOverrides to also mutate
chordBindings and the subKeys inside them. Specifically, when processing an
override whose commandId corresponds to a chord initiator (look up via
defaultActions for id "block:splitchord") update or remove entries in
chordBindings for the old/new key (add {key, subKeys} for rebinds; add
null-handler shadow entries for unbinds), and when an override targets a chord
subcommand (IDs from defaultChordActions) update the matching subKeys in any
chordBindings (replace its defaultKey or set handler to null) instead of only
pushing to bindings; use the existing actionHandlers, defaultChordActions,
chordBindings and bindings variables so appHandleKeyDown() and
globalChordBindings reflect user overrides.
| { | ||
| name: "Keybindings", | ||
| path: "keybindings.json", | ||
| language: "json", | ||
| description: "Custom keyboard shortcuts", | ||
| docsUrl: "https://docs.waveterm.dev/keybindings", | ||
| allowArray: true, | ||
| }, |
There was a problem hiding this comment.
Seed empty keybindings.json with [], not {}.
Adding an array-backed config file here makes the empty-file flow break: loadFile() still initializes blank JSON files with {\n\n}, so a new/empty keybindings.json immediately fails the "JSON must be an array" check on first save.
Suggested fix
- if (content.trim() === "") {
- globalStore.set(this.fileContentAtom, "{\n\n}");
+ if (content.trim() === "") {
+ globalStore.set(this.fileContentAtom, file.allowArray ? "[\n\n]" : "{\n\n}");
} else {
globalStore.set(this.fileContentAtom, content);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/view/waveconfig/waveconfig-model.ts` around lines 68 - 75, The
"Keybindings" config entry currently seeds an empty JSON file with an object,
which conflicts with loadFile()'s JSON initialization ({}), causing the "JSON
must be an array" check to fail; update the Keybindings entry (the object with
name "Keybindings" in waveconfig-model.ts) to seed an empty array instead of an
empty object (use [] as the initial content) so new/empty keybindings.json
passes the array validation when save/load run; keep the rest of the entry
(path, language, allowArray, etc.) unchanged and no changes to loadFile() are
required.
Edit: Discussed, this is going to be first iteration of keybindings support. Following VS Code guidelines. We will add chords and streamline keybindings to follow vs code format in next PRs.
This remains a draft until I manually verify if it's working properly and have a deep dive through the code.
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.