Files
CherryHQ-cherry-studio/docs/references/command/README.md
2026-07-06 16:47:10 +08:00

133 lines
7.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Command System
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 adhoc context menus), each of which used to maintain its
own definitions, keyformatting, and dispatch wiring.
- [command-usage.md](./command-usage.md) — how to register handlers, contribute
menus, render commandbacked UI, and add a new command.
## Commands, shortcuts, and menus — the relationship
**A command is *what* the app does; a shortcut is one *way* to ask for it.** They
are deliberately separate concepts:
- A **command** is the unit of behavior, identified by a `CommandId` (e.g.
`topic.create`, `app.zoom.in`, `chat.message.search`). It owns the behavior and
knows nothing about how it was triggered.
- A **shortcut** is a key binding *for* a command. A **menu item** is a menu entry
*for* a command. A **button** invokes a command. All of them are just triggers
that resolve to a `CommandId` and run its handler.
```
keyboard shortcut ─┐
menu item ─────────┼──▶ CommandId ──▶ handler (renderer or main)
button / palette ──┘
```
Two consequences fall out of this split:
- **There are no freefloating shortcuts.** Every shortcut, menu item, and button
resolves to a command. You never bind a key to an inline callback — you bind it
to a `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.
- **A command can have zero, one, or several triggers.** A command may be
menuonly (no default key), keyboardonly, or both; the keybinding even allows
`additionalBindings` (e.g. numpad zoom). The command is the same either way.
### How a command relates to its shortcut
| 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 builtin (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 mainprocess globalshortcut registrar
(`ShortcutService`) for main/global, or the perwindow keydown dispatcher
(`CommandProvider`) for renderer.
`COMMAND_DEFINITIONS` is the single source of truth — the `CommandId` union, the
keybinding rules, the percommand `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 `CommandId`s.
## Architecture — three layers
### 1. Shared declarations — `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`.
### 2. Main runtime — `src/main/services/`
| Service | Responsibility |
| --- | --- |
| `CommandService` | holds the mainside handler registry; `execute(command, window?, ctx?)` with context evaluation; wires builtin handlers (window/zoom/settings/quickassistant/selection); registers the native popup menu IPC (`NativeCommandPopupMenu_Show`) |
| `nativePopupMenu.ts` | stateless module — materializes a renderersupplied 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` (nonrenderer scope) → `CommandService.execute` |
| `AppMenuService` | builds the macOS app menu from `menuRegistry.resolve({ location: 'app.menu' })` via `menu/adapters/nativeMenuAdapter``CommandService.execute` |
### 3. Renderer runtime — `src/renderer/features/command/`
| Piece | Responsibility |
| --- | --- |
| `CommandProvider` | one windowlevel `keydown` dispatcher + the handler stack (`useCommandHandler`, `useCommandRuntime`) |
| `ContextKeyProvider` | windowlocal 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`.
### Preferences
- `shortcut.<commandId>``PreferenceShortcutType` (`{ binding, enabled }`), the
editable binding per command. Generated through the dataclassify pipeline (see
[command-usage.md](./command-usage.md#adding-a-command)).
- `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.
## Dispatch flows
- **Keyboard (renderer):** `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 `preventDefault`s when a command with a registered handler
resolves.
- **Keyboard (global):** OS `globalShortcut``ShortcutService`
`CommandService.execute(command, window)`.
- **Native menu:** renderer builds a `NativePopupMenuModel`
`window.api.command.showNativePopupMenu``CommandService`'s
`NativeCommandPopupMenu_Show` handler → `showNativePopupMenu` (in
`nativePopupMenu.ts`). Mainhandled commands run there; rendererhandled ones
are returned to the renderer runtime to execute.