feat: server-side configurable keybindings #492

Open
opened 2026-08-04 23:54:37 +02:00 by dries · 0 comments
Owner

Keybindings are hardcoded in the frontend, cannot be changed, and the registry that owns them can't express the modifiers the app actually uses.

The registry (frontend/src/lib/shortcutRegistry.ts, 322 lines) is well built — one capture-phase listener, scope precedence, e.code matching to survive the Mac Option rewrite — but it is in-memory only. rg localStorage frontend/src/lib/shortcutRegistry.ts finds nothing, and uiStore.ts:380 notes that shortcut state deliberately stays transient. There is no backend concept at all: rg -ni "keybind|keymap|shortcut" internal --glob '*.go' returns zero non-test matches.

It also refuses ctrl and meta outright (shortcutRegistry.ts:25-27, enforced at :148):

if (e.ctrlKey || e.metaKey) return false;

so every ctrl/cmd gesture has to bypass it. frontend/src/components/assistant/Composer.tsx:665-674 — the #58 queue gesture, one of the most important bindings in the app — is a hand-rolled handler for exactly this reason:

const queue = e.ctrlKey || e.metaKey;

Alongside it, ~18 more hardcoded handlers exist despite frontend/src/lib/shortcuts.ts:1-21 explicitly forbidding them: Tab/Shift+Tab agent cycling and Ctrl+C clear (Composer.tsx:625-663), Escape abort (:779-789), the permission and question prompt keys (PermissionPrompt.tsx:343, QuestionPrompt.tsx:302), the reasoning and command pickers, dictation push-to-talk, and one lone useHotkeys('esc') (App.tsx:276-280).

The 20 registered shortcuts are all Alt-based as a consequence, and every one of them is fixed.

Why it matters

Alt-only is a real constraint, not a stylistic one: Alt+letter collides with browser and window-manager bindings on Linux and Windows, and users who drive agents all day want their own keymap. Today the only way to change a binding is to fork.

Because there is no persistence layer, there is also nowhere to express the two things a keymap needs beyond "key to command": context (a key means one thing in the composer and another in a prompt) and precedence (a later rule taking a key away from an earlier one).

Suggested fix

Persist the keymap server-side and make the frontend registry a consumer of it.

  1. Storage. One setting row holding a JSON rule list, following the existing typed-accessor pattern (internal/state/settings.go:24-51): const settingKeybindings = "ui.keybindings", GetKeybindings() defaulting to the built-in set when unset, SetKeybindings(). The ok return already distinguishes "explicitly empty" from "never set", which is what "no custom binding" needs.
  2. API. GET/POST /api/settings/keybindings, copying handleWorktreeInheritPermissions (internal/server/handlers_settings.go:221-249) — one handler, switch r.Method, 503 when stateDB == nil, readAndUnmarshal + writeJSON.
  3. Rule shape. {command, key, when?}. Precedence is last matching rule wins, across commands, so a later rule can take a key away from an earlier one — that single rule is what makes user overrides and modal keymaps work without modes.
  4. when expressions. A tiny grammar: identifiers, !, &&, ||, parens, with a bounded depth. Parse to an AST once at load, not per keystroke. Context keys start as composerFocus, promptOpen, paletteOpen, terminalFocus, and unknown keys evaluate to false so an old ocman reading a newer keymap degrades instead of throwing.
  5. Defaults backfill. On load, merge the built-in defaults in, skipping any default whose command the user has already bound. New shipped shortcuts appear for existing users without ever overwriting a user's rule.
  6. Degrade, don't fail. An invalid rule is skipped; an unparseable blob falls back entirely to defaults. Both surface as a settings-page warning listing the offending index, not a crash and not silence.
  7. Support ctrl/meta. Lift the :148 restriction, normalise mod to cmd on macOS and ctrl elsewhere at parse time, and fold the hand-rolled Ctrl/Cmd+Enter queue gesture into the registry.
  8. Migrate the strays. Move the hardcoded handlers listed above onto the registry, or document per handler why it must stay element-local (a picker's internal arrow-key nav legitimately does).
  9. Settings UI. List commands with their effective binding, allow rebinding, show conflicts, and reset-to-default. Do not document the command list anywhere else — the settings page is the only copy that always matches the build.

Acceptance criteria

  • Keymap persists in state.db and survives restart; GET/POST /api/settings/keybindings follow the existing settings handler pattern.
  • Last-matching-rule-wins across commands, with a table-driven test including a later rule stealing a key from an earlier one.
  • when expressions parse to an AST at load; unknown context keys evaluate to false; depth is bounded. Fuzz the parser.
  • Newly shipped default shortcuts appear for a user with an existing custom keymap, and never overwrite a user rule for the same command.
  • An invalid rule is skipped and reported in Settings; an invalid blob falls back to defaults with a logged warning; neither breaks the app.
  • Ctrl/Cmd bindings work through the registry, and the Ctrl/Cmd+Enter queue gesture is registered rather than hand-rolled.
  • The hardcoded handlers are either migrated or carry a comment saying why they stay.
  • Settings UI lists every command with its effective binding and supports rebind, conflict display, and reset.

Effort: L.

Keybindings are hardcoded in the frontend, cannot be changed, and the registry that owns them can't express the modifiers the app actually uses. The registry (`frontend/src/lib/shortcutRegistry.ts`, 322 lines) is well built — one capture-phase listener, scope precedence, `e.code` matching to survive the Mac Option rewrite — but it is in-memory only. `rg localStorage frontend/src/lib/shortcutRegistry.ts` finds nothing, and `uiStore.ts:380` notes that shortcut state deliberately stays transient. There is no backend concept at all: `rg -ni "keybind|keymap|shortcut" internal --glob '*.go'` returns zero non-test matches. It also refuses ctrl and meta outright (`shortcutRegistry.ts:25-27`, enforced at `:148`): ```ts if (e.ctrlKey || e.metaKey) return false; ``` so every ctrl/cmd gesture has to bypass it. `frontend/src/components/assistant/Composer.tsx:665-674` — the #58 queue gesture, one of the most important bindings in the app — is a hand-rolled handler for exactly this reason: ```ts const queue = e.ctrlKey || e.metaKey; ``` Alongside it, ~18 more hardcoded handlers exist despite `frontend/src/lib/shortcuts.ts:1-21` explicitly forbidding them: Tab/Shift+Tab agent cycling and Ctrl+C clear (`Composer.tsx:625-663`), Escape abort (`:779-789`), the permission and question prompt keys (`PermissionPrompt.tsx:343`, `QuestionPrompt.tsx:302`), the reasoning and command pickers, dictation push-to-talk, and one lone `useHotkeys('esc')` (`App.tsx:276-280`). The 20 registered shortcuts are all Alt-based as a consequence, and every one of them is fixed. ## Why it matters Alt-only is a real constraint, not a stylistic one: Alt+letter collides with browser and window-manager bindings on Linux and Windows, and users who drive agents all day want their own keymap. Today the only way to change a binding is to fork. Because there is no persistence layer, there is also nowhere to express the two things a keymap needs beyond "key to command": context (a key means one thing in the composer and another in a prompt) and precedence (a later rule taking a key away from an earlier one). ## Suggested fix Persist the keymap server-side and make the frontend registry a consumer of it. 1. **Storage.** One `setting` row holding a JSON rule list, following the existing typed-accessor pattern (`internal/state/settings.go:24-51`): `const settingKeybindings = "ui.keybindings"`, `GetKeybindings()` defaulting to the built-in set when unset, `SetKeybindings()`. The `ok` return already distinguishes "explicitly empty" from "never set", which is what "no custom binding" needs. 2. **API.** `GET`/`POST /api/settings/keybindings`, copying `handleWorktreeInheritPermissions` (`internal/server/handlers_settings.go:221-249`) — one handler, `switch r.Method`, 503 when `stateDB == nil`, `readAndUnmarshal` + `writeJSON`. 3. **Rule shape.** `{command, key, when?}`. Precedence is **last matching rule wins, across commands**, so a later rule can take a key away from an earlier one — that single rule is what makes user overrides and modal keymaps work without modes. 4. **`when` expressions.** A tiny grammar: identifiers, `!`, `&&`, `||`, parens, with a bounded depth. Parse to an AST once at load, not per keystroke. Context keys start as `composerFocus`, `promptOpen`, `paletteOpen`, `terminalFocus`, and **unknown keys evaluate to false** so an old ocman reading a newer keymap degrades instead of throwing. 5. **Defaults backfill.** On load, merge the built-in defaults in, skipping any default whose *command* the user has already bound. New shipped shortcuts appear for existing users without ever overwriting a user's rule. 6. **Degrade, don't fail.** An invalid rule is skipped; an unparseable blob falls back entirely to defaults. Both surface as a settings-page warning listing the offending index, not a crash and not silence. 7. **Support ctrl/meta.** Lift the `:148` restriction, normalise `mod` to cmd on macOS and ctrl elsewhere at parse time, and fold the hand-rolled Ctrl/Cmd+Enter queue gesture into the registry. 8. **Migrate the strays.** Move the hardcoded handlers listed above onto the registry, or document per handler why it must stay element-local (a picker's internal arrow-key nav legitimately does). 9. **Settings UI.** List commands with their effective binding, allow rebinding, show conflicts, and reset-to-default. Do not document the command list anywhere else — the settings page is the only copy that always matches the build. ## Acceptance criteria - [ ] Keymap persists in `state.db` and survives restart; `GET`/`POST /api/settings/keybindings` follow the existing settings handler pattern. - [ ] Last-matching-rule-wins across commands, with a table-driven test including a later rule stealing a key from an earlier one. - [ ] `when` expressions parse to an AST at load; unknown context keys evaluate to false; depth is bounded. Fuzz the parser. - [ ] Newly shipped default shortcuts appear for a user with an existing custom keymap, and never overwrite a user rule for the same command. - [ ] An invalid rule is skipped and reported in Settings; an invalid blob falls back to defaults with a logged warning; neither breaks the app. - [ ] Ctrl/Cmd bindings work through the registry, and the Ctrl/Cmd+Enter queue gesture is registered rather than hand-rolled. - [ ] The hardcoded handlers are either migrated or carry a comment saying why they stay. - [ ] Settings UI lists every command with its effective binding and supports rebind, conflict display, and reset. Effort: L.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dries/ocman#492
No description provided.