Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

VariantWhen Used
callbackAlways available. Runs unconditionally.
checkCallbackContext-dependent. Called with checking=true to test availability, checking=false to execute.
editorCallbackOnly when an editor is active. Receives the active Editor and MarkdownView.
editorCheckCallbackEditor-scoped + conditional. Combines both patterns.

CommandManager

CommandManager (packages/api/src/lib/command.svelte.ts) extends EventDispatcher and manages all registered commands.

State

PropertyTypeDescription
commandsMap<string, Command>All registered commands
editorCommandsMap<string, Command>Subset with editorCallback or editorCheckCallback
bindingsMap<string, Command[]>Effective hotkey lookup table
hotkeyOverridesMap<string, Hotkey[]>Vault-scoped custom hotkey overrides
open$state<boolean>Whether the command palette is open
openHostIdstringCommand host currently owning the open palette

Methods

MethodDescription
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

MethodDescription
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, Meta
  • isModEvent(event) — checks event.metaKey on macOS, event.ctrlKey elsewhere
  • 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 Mod matches Meta on macOS and Ctrl on other platforms instead of storing a literal Mod binding.

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 when clauses
  • 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.