Command and Keymap System
Commands provide a uniform dispatch mechanism for user actions. The keymap system binds keyboard shortcuts to commands with scope-aware priority.
Command Interface
interface Command {
id: string; // Unique ID, e.g. "editor:bold"
name: string; // Human-readable label
title?: string; // Unprefixed display title
category?: string; // Optional grouping/category label
icon?: string; // Icon identifier for UI
sourcePlugin?: string; // Owning plugin ID
activationEvent?: string; // Deferred activation trigger
argumentSchema?: Record<string, unknown>; // Optional structured arguments
when?: string; // Declarative context-key expression
hotkeys?: Hotkey[]; // Default keyboard shortcuts
callback?: () => void;
checkCallback?: (checking: boolean) => boolean | void;
editorCallback?: (editor: Editor, view: MarkdownView) => void;
editorCheckCallback?: (
checking: boolean,
editor: Editor,
view: MarkdownView,
) => boolean | void;
}
Callback Variants
| Variant | When Used |
|---|---|
callback | Always available. Runs unconditionally. |
checkCallback | Context-dependent. Called with checking=true to test availability, checking=false to execute. |
editorCallback | Only when an editor is active. Receives the active Editor and MarkdownView. |
editorCheckCallback | Editor-scoped + conditional. Combines both patterns. |
CommandManager
CommandManager (packages/api/src/lib/command.svelte.ts) extends EventDispatcher and manages all registered commands.
State
| Property | Type | Description |
|---|---|---|
commands | Map<string, Command> | All registered commands |
editorCommands | Map<string, Command> | Subset with editorCallback or editorCheckCallback |
bindings | Map<string, Command[]> | Effective hotkey lookup table |
hotkeyOverrides | Map<string, Hotkey[]> | Vault-scoped custom hotkey overrides |
open | $state<boolean> | Whether the command palette is open |
openHostId | string | Command host currently owning the open palette |
Methods
| Method | Description |
|---|---|
addCommand(command) | Register a command. Emits register. |
removeCommand(id) | Un-register. Emits unregister. |
executeCommand(command) | Run a command against the currently focused command host. |
executeCommandById(id) | Look up and execute against the focused command host. |
executeCommandForHost(id, hostId) | Look up and execute against an explicit root or popup host. |
findCommand(id) | Retrieve a command by ID. |
listCommands() | Return all commands as an array. |
isCommandAvailable(id, hostId?) | Evaluate availability against the focused or explicit command host. |
isOpenForHost(hostId) | Report whether the palette is open for a given host. |
getAvailableCommands(hostId?) | Return commands executable for the focused or explicit host. |
getCommandMetadata(id) | Return normalized metadata for declarative consumers. |
loadHotkeys() / saveHotkeys() | Load and persist custom hotkeys in .obsidian/hotkeys.json. |
getEffectiveHotkeys(id) | Return override hotkeys when customized, otherwise command defaults. |
setHotkeys() / addHotkey() / removeHotkey() | Edit command-specific overrides and rebuild bindings. |
resetHotkeys(id) | Remove the override so the command uses defaults again. |
getHotkeyAssignments() / getHotkeyConflicts() | Return settings UI data and duplicate-hotkey diagnostics. |
Custom hotkeys are stored in Obsidian-compatible .obsidian/hotkeys.json as
{ [commandId]: Hotkey[] }. A missing command key means “use command
defaults”; an empty array means the command is explicitly unbound. Loading
malformed hotkey files is non-fatal and falls back to defaults. Hotkey changes
emit hotkeys-updated, rebuild the command binding table immediately, and keep
conflicts visible without blocking the save.
Editor Command Execution
When command execution encounters an editorCallback or editorCheckCallback, it resolves the target leaf through the workspace command-host model first. The root shell uses WORKSPACE_ROOT_HOST_ID, while each popup WorkspaceWindow contributes its own host id. Workspace.getCommandHostLeaf(hostId) and focused-host tracking therefore let command availability and execution use the popup’s active editor instead of always falling back to the root window’s active leaf. If no compatible editor is active for that host, the command is skipped.
Hotkey Types
interface Hotkey {
modifiers: Modifier[];
key: string; // KeyboardEvent.key value
}
type Modifier = "Mod" | "Ctrl" | "Meta" | "Shift" | "Alt";
"Mod" is the platform-aware modifier: Meta on macOS, Ctrl on Windows/Linux.
Keymap
Keymap (packages/api/src/lib/scope.svelte.ts) manages a stack of Scope objects. The topmost scope gets first chance to handle a keyboard event.
Scope
A Scope represents a keyboard context (e.g., the editor, a modal, a suggest popup):
class Scope {
keyBindings: KeyBinding[];
parent?: Scope;
register(
modifiers: Modifier[],
key: string | null,
handler: KeymapEventHandler,
): KeyBinding;
unregister(binding: KeyBinding): void;
handleEvent(event: KeyboardEvent, info: KeymapInfo): boolean;
}
Scope.register() supports modal wildcard capture in addition to exact hotkeys. Passing key = null matches any non-modifier key, modifiers = null matches any modifier combination, and the resolver checks exact keybindings before falling back through modifier-scoped and fully wildcard handlers. This keeps modal input flows such as hint overlays or command-like mini modes on the existing keymap stack instead of attaching ad-hoc document listeners.
Keymap Stack
| Method | Description |
|---|---|
pushScope(scope) | Push a scope on top. Used when modals/suggests open. |
popScope(scope) | Remove a scope. Used when modals/suggests close. |
handleEvent(event) | Walk the stack top-to-bottom. First handler that returns false stops propagation. |
Keyboard Event Routing
1. Browser fires keydown event
2. Keymap.handleEvent receives it
3. Walk scope stack from top to bottom:
a. For each scope, iterate keyBindings
b. Match modifiers + key against the event
c. If handler returns false → stop (event consumed)
d. If handler returns undefined → continue to next binding/scope
4. If no scope handler consumes the event, command lookup uses effective
hotkeys from `CommandManager`
5. The first available command bound to the key executes through
`executeCommand()`
6. If no scope or command handles it, event propagates normally
Modifier Normalization
Keymap normalizes modifiers for cross-platform use:
isModifier(key)— returns true for Shift, Control, Alt, MetaisModEvent(event)— checksevent.metaKeyon macOS,event.ctrlKeyelsewhere- Internal matching converts
"Mod"to the platform-specific modifier before comparison - Command hotkey registration and lookup use the same normalization path, so a command registered with
ModmatchesMetaon macOS andCtrlon other platforms instead of storing a literalModbinding.
Document-level workspace keydown forwarding should let any pushed modal scopes on app.keymap run before the active view scope, then fall back to app.scope before allowing the browser or host default to run. This keeps app-global shortcuts such as the command palette available even when the active leaf owns its own scope, while still letting modal overlays consume input before editor widgets or browser defaults see it. Popup shells register the same forwarding against their own document and window so Mod+P and related shortcuts resolve against the focused popup host instead of the parent renderer document.
Command Palette Integration
The command palette (CommandPalette in packages/workspace) lists all commands via commandManager.listCommands(). Workspace-owned command and file dialogs rank visible entries with the shared @lapis-notes/ui Fuse.js helper while keeping bits-ui command filtering disabled, and selection still calls commandManager.executeCommand(). Displayed shortcut badges come from getEffectiveHotkeys() so custom hotkeys and explicit unbindings match runtime behavior.
Command labels accept VS Code-style label icon text. The shared parser in
@lapis-notes/api splits plain text from $(token) segments. Unqualified
tokens such as $(play) and $(sync~spin) render with the VS Code codicon font
first; when no codicon glyph exists, the renderer falls back to a registered
Iconify icon with the same id (play, lucide:play, custom:…). Qualified
tokens such as $(lucide:file-text) or $(vscode-icons:file-type-json) render
inline SVG from registered packs only. Unknown or malformed tokens stay visible
as literal $(…) text. The ~spin modifier applies to both codicon and
registry segments.
The command palette and future declarative surfaces should use isCommandAvailable(), getAvailableCommands(), host-aware palette open state, and command metadata queries instead of re-implementing checkCallback or editor gating inline. Deferred manifest commands remain registered as placeholders until execution triggers the matching onCommand:<id> activation event.
Workspace-owned palette surfaces mount per command host. The root renderer can keep its normal overlay behavior, but popup shells must render their palette surface inside the popup document rather than portal across documents. Host-local mounting keeps focus management, key handling, and command execution attached to the same document that opened the palette.
isCommandAvailable() now evaluates a command’s optional when expression through App.contextKeys before any callback-specific availability check runs. A falsy or malformed when expression hides the command from palette-style surfaces and makes executeCommand() reject it as unavailable.
The current command registry is already the lifecycle owner for imperative and manifest-indexed commands. The planned VS Code-style expansion tracked in the backlog is to treat it as the shared command surface for declarative menus, keybindings, status items, prompt flows, and lazy command activation rather than introducing separate command dispatch paths per feature.
Plugin Command Registration
Plugins register commands via Plugin.addCommand(), which delegates to app.commands.addCommand(). The command is automatically removed when the plugin is disabled.
class MyPlugin extends Plugin {
onload() {
this.addCommand({
id: "my-plugin:do-thing",
name: "Do Thing",
hotkeys: [{ modifiers: ["Mod"], key: "d" }],
callback: () => { ... },
});
}
}
Planned Declarative Layers
The next command-system slices tracked in the backlog build on the existing registry:
- manifest keybinding contributions with platform overrides and
whenclauses - declarative menu and context-menu contributions that reference registered commands
- richer command metadata and diagnostics for duplicate IDs or invalid command references
These additions should reuse the current command registry and keymap stack so command execution remains observable, disposable, and plugin-owned in one place.