⌘K is the app's index, and a bindable command is a contract: its id is what a
user writes in settings.yaml's keybindings: section, so it outlives every
rename of the code behind it. The failure mode of both is silent. A feature
ships, nobody registers a row or a command, and the palette quietly stops
being complete.
The doctrine is in docs/architecture.md (the keymap section and "The command
palette's rules sit beside the keymap doctrine") plus these ADRs. Read them
before deviating, not before every audit:
- keybindings-are-chord-sequences-not-a-leader-key
- palette-scopes-are-filters-over-one-list
- palette-rows-name-objects-and-rendering-draws-the-path
- a-command-is-a-source
1. Scope the change
git diff main...HEAD --stat
git diff main...HEAD -- cmd/desktop/frontend/src
For a PR that is not checked out, gh pr diff <number>.
2. Inventory what a user gained
Write these down before deciding anything:
- verbs -- a new button, menu entry, context-menu item, toolbar icon, or
anything with an
@clicka user can reach; - objects -- a new kind of thing that can be listed and selected (a workspace, a canvas, a pinned chat, a window, a source);
- places -- a new view, overlay, dialog, mode, or settings section;
- configured things -- a new action type, launcher, or flow surface that a
user's
actions.ymlcan now name.
An internal refactor, a change to how an existing row is drawn, and a backend-only change all inventory to nothing. Say so and stop. An audit that finds nothing is a valid outcome and is reported as one.
3. Classify each one
A stable app verb is a command. Something a user does repeatedly, that
means the same thing every time, and that has one implementation: new views,
overlays, toggles, and lifecycle operations. It goes in the catalog (step 4),
and useAppPaletteRows seeds its palette row from there automatically.
A verb that only exists where its object does is a row, not a command.
Killing the attached session, unpinning this chat, deleting this workspace:
these act on state a component owns, so they register as scope: 'actions'
rows from that component and are not bindable. TerminalMode.vue's
terminal:session:kill is the pattern. Group them under the object's own
name.
An object is data, not a command. Feeds, profiles, sessions, windows,
chats, workspaces, themes, settings sections: register these with
useCommands as a reactive source (step 6). They are never catalog entries.
These are neither, and saying so is half the audit:
- Widget-local navigation. The session tree's arrows and
j/k, the palette's own arrows. A combo resolves to exactly one command (the first in the catalog claiming it), socontextnarrows when a command fires but never lets two commands share a chord. Those stay handlers on the widget that owns focus. - Anything reached once per session from a settings page or a dialog.
- A launcher or a configured action. Each
launchers:entry inactions.ymlalready contributeslauncher.<id>throughsetLauncherCommands, unbound by default, and actions reach the palette through the selected item's own rows. A new action type needs nothing; check it flows through those paths rather than building a parallel one. - A numbered variant.
terminal.select-window-1…-9are generated. A tenth is deliberately unreachable.
4. Add a command to the catalog
cmd/desktop/frontend/src/keybindings/catalog.ts, in commandCatalog:
{
id: 'notes.toggle', // <area>.<verb>, dot-separated, permanent
title: 'Toggle Notes', // the exact string Settings ▸ Keyboard shows
group: 'View', // shared with the palette's grouping
keywords: ['notes', 'scratch'],
icon: IconNotebook,
defaultCombos: [], // canonical combos; [] = bindable, unbound
context: 'global',
}
tasks.toggle in the same file is the worked version, with a default chord,
a sequence, piercesPane, and a comment saying why.
idis config.<area>.<verb>, lower-case, dots. It is written into a user'ssettings.yaml, so choose it once and never rename it to match a refactor.contextgates where a bare, modifier-less binding fires:global,feed,terminal,terminal-session,agents.contextActiveinApp.vueenforces it; a new context means teaching that switch what "active" means for it.- Combos are canonical strings, not display glyphs.
mod+shift+t,alt+t,g t: lower-case, modifiers ordered bycanonicalizeCombo, sequence steps space-joined.modis Command on macOS and Ctrl elsewhere, so check amodchord against readline on Linux.formatCombomakes the ⌘-and-⇧ form; never write that form into the catalog. defaultCombos: []is the right default. Ship a default only for a platform standard (⌘,,⌘N,⌘[/⌘]) or a command used often enough to earn it. A destructive command with no undo stays unbound on purpose (feed.mark-workspace-read).- Check the combo is free before you claim it. Catalog order decides
ties, so an added entry can silently shadow a later one. Launchers are
appended after the catalog so a config file cannot take
mod+kfrom the palette. - Add
scope: 'goto'when the command navigates rather than acts, andpaletteHidden: trueonly when a named dynamic row already stands in for it (asview.focus-searchdoes per surface).
Wire the implementation
One entry in App.vue's runMap, keyed by the id:
'tasks.toggle': openTasks,
The keydown dispatcher and the palette both run through that map. Launchers
and the numbered window jumps are the exceptions, resolved ahead of the map in
runCommand.
A catalog entry with no runMap entry fails silently. The row appears in
the palette, the shortcut appears in Settings, and the key does nothing.
Check the pair by hand.
A focused terminal pane
A pane keeps every key it can use. Two flags take one back:
escapesPane, claimed throughterminalEscapeCombo: Command chords, and Ctrl+Shift where there is no Command. Prefer this. It leaves bare Ctrl+K as readline's kill-to-end-of-line while ⌘K is the palette's.piercesPane, claimed on the binding alone, whatever modifiers it carries. Only for a chord the escape form cannot express (analtbinding), a combo that must close what it opened (terminal.popup.toggle,tasks.toggle,canvas.toggle), or the half of a focus pair that reaches back out of a pane.
Both are two-sided: the dispatcher acts on the chord and xterm's
attachCustomKeyEventHandler declines it, or the pane writes the key to tmux
as well. If you cannot answer "why may a pane not have this key?", set
neither flag.
Sequences
A binding is one combo or a space-separated sequence. Navigation to a place
uses g <x> (g i, g c, g a, g t, g v, g s). A sequence start never fires
over a focused pane, into an editable target, or under an overlay, and
resolve stays single-step. Adding a g <x> means checking nothing else
binds bare x in an overlapping context.
5. Check the command's palette row
The catalog seeds the row, so confirm it appears: it is not paletteHidden,
its context is active wherever the verb makes sense, and its title is the
catalog title unchanged, since Settings ▸ Keyboard reads the same string.
6. Register object and action rows
useCommands(computed(() => workspaces.value.map((w) => ({
id: `agents:workspace:${w.dir}`,
title: w.name, // the object's own name
group: 'Workspaces', // its real container
kind: 'workspace', // the muted type word at the row's right edge
scope: 'goto',
icon: IconFolder,
run: () => open(w.dir),
}))))
Go-to rows are global. A row meant to be reachable from anywhere
registers at App level, in useAppPaletteRows, off a module-scoped source
(useTerminalSessions, useAttachedTerminalWindows, useAgentSessionsAll,
useWorkspaceCanvases), so it exists on a fresh launch before its mode has ever mounted. A row
registered inside a lazily-mounted mode does not exist until the user visits
that mode.
A row registered inside a mode (TerminalMode.vue) is correct only when it
acts on state that lives in that component. Gate it on active inside the
getter: a mode is mounted once and then hidden.
Row shape:
- An object row is titled with the object's own name and grouped under
its real container. Never encode a path or a verb in the title:
"Desktop", not"Switch to profile: Desktop". kindis for object rows only.scope: 'goto'browses,scope: 'actions'acts. Default isactions.orderplaces the group: lower sorts earlier, and a group sorts as early as its earliest row asks.- Ids must be stable across renders. Recents are stored per row id in
localStorage, so an id built from an index or a timestamp breaks them.
Dynamic rows use colons (
terminal:session:kill); catalog ids use dots. - Hidden, never disabled. A row that cannot run where the user is standing is filtered out by its getter, not greyed (ADR quick-terminal-launchers-are-session-scoped).
Adding a scope tab is one entry in cmd/desktop/frontend/src/palette/scopes.ts.
A sigil (@, >, !, ?) is grammar: setQuery absorbs it only as the
first character of an empty query.
7. Verify
mise run desktop:frontend:test
Where to add tests:
composables/__tests__/useKeybindings.spec.ts: resolution and conflicts;components/__tests__/KeybindingSettingsView.spec.ts: the editor row;src/__tests__/App.spec.ts: dispatch, sequences, and the global Go-to rows;composables/__tests__/useCommands.spec.ts: scoring and the registry;components/__tests__/CommandPalette.spec.ts: rendering.
Then check by hand in a running app (mise run desktop:dev):
- a new command appears in Settings ▸ Keyboard, rebinds cleanly, and the key
does something (the
runMappair); - a new row appears from a cold start, in a mode you have not visited yet.
Report
State what you added, and state what you decided not to add and why. "This is a widget-local key", "this earns a catalog entry but no default chord", and "this is an object row, not a command" are all conclusions worth writing down, because the next audit re-asks the same questions.