feat: server-side configurable keybindings #492
Labels
No labels
backend
bug
chore
duplication
effort:complex
effort:medium
effort:trivial
enhancement
follow-up
frontend
fullstack
priority:high
ready-for-agent
refactor
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
dries/ocman#492
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.codematching to survive the Mac Option rewrite — but it is in-memory only.rg localStorage frontend/src/lib/shortcutRegistry.tsfinds nothing, anduiStore.ts:380notes 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):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:Alongside it, ~18 more hardcoded handlers exist despite
frontend/src/lib/shortcuts.ts:1-21explicitly 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 loneuseHotkeys('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.
settingrow 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(). Theokreturn already distinguishes "explicitly empty" from "never set", which is what "no custom binding" needs.GET/POST /api/settings/keybindings, copyinghandleWorktreeInheritPermissions(internal/server/handlers_settings.go:221-249) — one handler,switch r.Method, 503 whenstateDB == nil,readAndUnmarshal+writeJSON.{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.whenexpressions. A tiny grammar: identifiers,!,&&,||, parens, with a bounded depth. Parse to an AST once at load, not per keystroke. Context keys start ascomposerFocus,promptOpen,paletteOpen,terminalFocus, and unknown keys evaluate to false so an old ocman reading a newer keymap degrades instead of throwing.:148restriction, normalisemodto cmd on macOS and ctrl elsewhere at parse time, and fold the hand-rolled Ctrl/Cmd+Enter queue gesture into the registry.Acceptance criteria
state.dband survives restart;GET/POST /api/settings/keybindingsfollow the existing settings handler pattern.whenexpressions parse to an AST at load; unknown context keys evaluate to false; depth is bounded. Fuzz the parser.Effort: L.