docs/references/command/README.md
The command system is the single source of truth for what the app can do and the wiring that lets a keyboard shortcut, an application/context menu item, or a button all trigger the same behavior.
It replaces three previously independent systems (keyboard shortcuts, the native application menu, and ad‑hoc context menus), each of which used to maintain its own definitions, key‑formatting, and dispatch wiring.
A command is what the app does; a shortcut is one way to ask for it. They are deliberately separate concepts:
CommandId (e.g.
topic.create, app.zoom.in, chat.message.search). It owns the behavior and
knows nothing about how it was triggered.CommandId and run its handler. keyboard shortcut ─┐
menu item ─────────┼──▶ CommandId ──▶ handler (renderer or main)
button / palette ──┘
Two consequences fall out of this split:
CommandId, and a surface registers the handler separately. Adding a new
way to trigger something never touches the behavior, and changing the behavior
never touches its triggers.additionalBindings (e.g. numpad zoom). The command is the same either way.| Concept | Where it lives | Example for topic.create |
|---|---|---|
| Command definition | COMMAND_DEFINITIONS (src/shared/command/definitions.ts) | { id: 'topic.create', scope: 'renderer', keybinding: { defaultBinding: ['CommandOrControl','N'] } } |
| Default key binding | the command's keybinding.defaultBinding | Cmd/Ctrl + N |
| User override | the preference shortcut.<commandId> | shortcut.topic.create → { binding, enabled } |
| Handler | a surface via useCommandHandler (renderer) or a built‑in (main) | useCommandHandler('topic.create', addNewTopic) |
| Menu entry (optional) | a MENU_CONTRIBUTIONS entry | { location: 'chat.input.tools.context', command: 'topic.create' } |
So there is exactly one shortcut preference key per command (shortcut.<id>):
the command's default binding comes from its definition, and the user's edit in
Settings → Shortcuts overrides it through that preference key. At runtime the
effective binding is "user preference if set, else the definition default".
A command's scope: 'main' | 'renderer' | 'both' decides where its handler runs
and who listens for its key: the main‑process global‑shortcut registrar
(ShortcutService) for main/global, or the per‑window keydown dispatcher
(CommandProvider) for renderer.
COMMAND_DEFINITIONS is the single source of truth — the CommandId union, the
keybinding rules, the per‑command shortcut.<id> key, and the when/enablement
context expressions are all derived from it. Menu contributions are a parallel
declaration (MENU_CONTRIBUTIONS) keyed by the same CommandIds.
src/shared/command/Pure data and pure functions, no Electron or React.
| File | Responsibility |
|---|---|
definitions.ts | COMMAND_DEFINITIONS (SoT), the derived CommandId, KEYBINDING_RULES, REGISTERED_KEYBINDINGS, lookups |
keybindings.ts | resolve a binding → command, default/effective shortcut preference, conflict detection, label formatting |
menus.ts | MENU_CONTRIBUTIONS, the MenuRegistry, and resolveMenuPresentationMode |
contextExpr.ts | parser/evaluator for when/enablement expressions + ContextKeyService |
types.ts | all command/keybinding/menu/context types |
Token formatting (typed shortcut vocabulary, normalization, display/accelerator
formatting) lives in src/shared/shortcuts/tokens.ts; src/shared/shortcuts/types.ts
keeps only ShortcutPreferenceKey + ResolvedShortcut.
src/main/services/| Service | Responsibility |
|---|---|
CommandService | holds the main‑side handler registry; execute(command, window?, ctx?) with context evaluation; wires built‑in handlers (window/zoom/settings/quick‑assistant/selection); registers the native popup menu IPC (NativeCommandPopupMenu_Show) |
nativePopupMenu.ts | stateless module — materializes a renderer‑supplied menu model into an Electron native popup and reports the chosen command back; CommandService injects the execute/gate callback |
ShortcutService | registers globalShortcut accelerators from REGISTERED_KEYBINDINGS (non‑renderer scope) → CommandService.execute |
AppMenuService | builds the macOS app menu from menuRegistry.resolve({ location: 'app.menu' }) via menu/adapters/nativeMenuAdapter → CommandService.execute |
src/renderer/features/command/| Piece | Responsibility |
|---|---|
CommandProvider | one window‑level keydown dispatcher + the handler stack (useCommandHandler, useCommandRuntime) |
ContextKeyProvider | window‑local context keys (useCommandContextKey) |
presentation.tsx | CommandShortcut, CommandTooltip, CommandButton, useResolvedCommand |
menus.tsx | CommandContextMenu — renders Cherry UI or a native popup based on menu.presentation_mode |
Mount <ContextKeyProvider><CommandProvider> once per renderer window — every
window root mounts it: windows/main/MainApp.tsx and windows/subWindow/SubWindowApp.tsx.
shortcut.<commandId> — PreferenceShortcutType ({ binding, enabled }), the
editable binding per command. Generated through the data‑classify pipeline (see
command-usage.md).menu.presentation_mode — 'cherry' | 'native'. Read by CommandProvider
to choose the menu renderer. There is no settings UI for it yet (planned); it
currently defaults via the preference schema.keydown → CommandProvider →
getShortcutBindingFromKeyboardEvent →
resolveCommandByKeybinding({ scope: 'renderer', canExecuteCommand: hasHandler })
→ active handler. While an editable target (<input>, <textarea>, or a
contenteditable element) is focused the dispatcher skips no-modifier
shortcuts so typing isn't hijacked; modifier shortcuts (Ctrl/Meta/Alt) still
fire. It only preventDefaults when a command with a registered handler
resolves.globalShortcut → ShortcutService →
CommandService.execute(command, window).NativePopupMenuModel →
window.api.command.showNativePopupMenu → CommandService's
NativeCommandPopupMenu_Show handler → showNativePopupMenu (in
nativePopupMenu.ts). Main‑handled commands run there; renderer‑handled ones
are returned to the renderer runtime to execute.