Skip to content

feat: add custom keybindings support with VSCode-style configuration#3142

Open
mswiszcz wants to merge 7 commits intowavetermdev:mainfrom
mswiszcz:feat/custom-keybindings
Open

feat: add custom keybindings support with VSCode-style configuration#3142
mswiszcz wants to merge 7 commits intowavetermdev:mainfrom
mswiszcz:feat/custom-keybindings

Conversation

@mswiszcz
Copy link
Copy Markdown

@mswiszcz mswiszcz commented Mar 27, 2026

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.

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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Walkthrough

This pull request introduces a comprehensive keybindings customization system for Wave. The implementation spans the entire stack: documentation defines the user-facing customization process via ~/.config/waveterm/keybindings.json; a new JSON schema validates keybinding structure; backend code in Go reads and parses the keybindings file; frontend types and Jotai atoms manage keybindings state across the application; key parsing is made case-insensitive; and the core key mapping logic in keymodel.ts is refactored to support ordered bindings with user override precedence, chord handling, and dynamic reloading. New default keybindings for block focus cycling (Ctrl:Shift:] and Ctrl:Shift:[) are included alongside infrastructure for registering and hot-reloading user-defined overrides.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature added: custom keybindings support with VS Code-style configuration, which is the central theme of all changes across documentation, schema, backend, and frontend code.
Description check ✅ Passed The description clearly relates to the changeset by outlining the refactored keybindings system with declarative action registry, user override support, JSON schema, config sidebar, documentation, and Go backend—all present in the changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

mswiszcz and others added 6 commits March 28, 2026 00:36
…-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>
@mswiszcz mswiszcz marked this pull request as ready for review March 31, 2026 08:44
let keys = keyDescription.replace(/[()]/g, "").split(":");
for (let key of keys) {
if (key == "Cmd") {
const keyLower = key.toLowerCase();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot bot commented Mar 31, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Overview

The PR implements custom keybindings support with VSCode-style configuration. It adds:

  • A new keybindings.json config file for user keybinding overrides
  • JSON schema validation
  • Frontend keybinding atom and integration
  • Documentation updates
  • New actions for cycling block focus (CW/CCW)

Files Reviewed (18 files)

  • docs/docs/keybindings.mdx - Documentation additions
  • frontend/app/monaco/schemaendpoints.ts - Monaco schema registration
  • frontend/app/store/global-atoms.ts - New keybindingsAtom
  • frontend/app/store/keymodel.ts - Refactored to action registry pattern
  • frontend/app/view/waveconfig/waveconfig-model.ts - Config file for keybindings
  • frontend/preview/mock/defaultconfig.ts - Default config update
  • frontend/preview/mock/mockwaveenv.ts - Mock environment update
  • frontend/types/custom.d.ts - Type definition
  • frontend/types/gotypes.d.ts - Generated types
  • frontend/util/keyutil.ts - Case handling (lowercase)
  • frontend/wave.ts - Initialization
  • pkg/wconfig/defaultconfig/keybindings.json - Default empty config
  • pkg/wconfig/settingsconfig.go - Config loading
  • schema/keybindings.json - JSON schema

Note

The 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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 96c2526 and e70ddfc.

📒 Files selected for processing (14)
  • docs/docs/keybindings.mdx
  • frontend/app/monaco/schemaendpoints.ts
  • frontend/app/store/global-atoms.ts
  • frontend/app/store/keymodel.ts
  • frontend/app/view/waveconfig/waveconfig-model.ts
  • frontend/preview/mock/defaultconfig.ts
  • frontend/preview/mock/mockwaveenv.ts
  • frontend/types/custom.d.ts
  • frontend/types/gotypes.d.ts
  • frontend/util/keyutil.ts
  • frontend/wave.ts
  • pkg/wconfig/defaultconfig/keybindings.json
  • pkg/wconfig/settingsconfig.go
  • schema/keybindings.json

Comment on lines +118 to +123
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`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +124 to +130
### 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" }
]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +549 to +577
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +592 to +605
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) {
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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:


🏁 Script executed:

# First, let's check the actual code at the lines mentioned
head -n 610 frontend/app/store/keymodel.ts | tail -n 30

Repository: wavetermdev/waveterm

Length of output: 786


🏁 Script executed:

# Also check lines 696-705
sed -n '690,710p' frontend/app/store/keymodel.ts

Repository: wavetermdev/waveterm

Length of output: 579


🏁 Script executed:

# Now let's look at the checkKeyPressed implementation
cat frontend/util/keyutil.ts

Repository: 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.

Comment on lines +903 to +956
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}`);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +68 to +75
{
name: "Keybindings",
path: "keybindings.json",
language: "json",
description: "Custom keyboard shortcuts",
docsUrl: "https://docs.waveterm.dev/keybindings",
allowArray: true,
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant