Core keymap

This page covers @opentui/keymap without any host-specific adapter.

Use the bare engine when you want a custom host or when you want to build addons on top of the shared registration surface. If you want a ready-made host or need the host contract, start with Hosts.

Construct a keymap

import { Keymap, type KeymapHost } from "@opentui/keymap"

function createKeymap(host: KeymapHost<object>) {
  return new Keymap(host)
}

Bare keymaps do not parse string bindings until you install binding parsers and event match resolvers. In practice that usually means registerDefaultKeys() or one of the default host helpers.

KeymapHost

KeymapHost is the contract that built-in hosts and custom adapters implement. See Hosts for the built-in OpenTUI and HTML adapters.

Member Required Description
metadata yes Platform, primary shortcut modifier and modifier capability metadata
rootTarget yes Root target for the host hierarchy
isDestroyed yes Host lifetime flag
getFocusedTarget() yes Returns the currently focused target or null
getParentTarget(target) yes Parent traversal used for focus-within matching
isTargetDestroyed(target) yes Target liveness check
onKeyPress(listener) yes Subscribe to press events
onKeyRelease(listener) yes Subscribe to release events
onFocusChange(listener) yes Subscribe to focus changes
onTargetDestroy(target, fn) yes Subscribe to target disposal/removal
createCommandEvent() yes Creates the synthetic event used by runCommand() and dispatchCommand()
onDestroy(listener) no Optional host-destroy notification
onRawInput(listener) no Optional raw input hook, used before host key parsing. The listener returns true when it consumed the sequence

Host key events must satisfy KeymapEvent:

Member Description
name Normalized key name
ctrl / shift / meta Modifier state
super / hyper Optional extra modifier state
preventDefault() Prevents the matched event from reaching the host target
stopPropagation() Stops later keymap/host listeners from seeing the event and sets propagationStopped to true
propagationStopped true after stopPropagation() was called

Layers

keymap.registerLayer({
  commands: [
    { name: "save-file", title: "Save File", run() {} },
    { name: "quit", run() {} },
  ],
})

keymap.registerLayer({
  target: editor,
  targetMode: "focus-within",
  priority: 10,
  bindings: [
    { key: "ctrl+s", cmd: "save-file", desc: "Save file" },
    { key: "q", cmd: "quit", preventDefault: false },
  ],
})
Field Description
target Optional local target. Omit it for a global layer
targetMode "focus-within" or "focus". Defaults to "focus-within" when target is present
priority Higher numbers win. Within the same priority, newer layers win
bindings Binding[]
commands Named command definitions registered with the same layer
extra fields Custom fields used by registered layer-field compilers and binding pipeline addons
  • Targetless layers are always active.
  • Active layers all use the same precedence rules: higher priority wins, then newer layers win.
  • Returning false from a command rejects that candidate so lower-precedence handlers can continue.
  • fallthrough and preventDefault are independent. fallthrough continues inside the keymap; preventDefault controls whether the event escapes to the host.

Bindings

Form Example Notes
String stroke "x", "ctrl+x", "return" Requires a registered binding parser
String sequence "dd", "<leader>s", "{count}j" Concatenated multi-stroke sequence
Object stroke { name: "return", ctrl: true } Skips string parsing and normalizes directly
Release binding { key: "a", event: "release", ...} Release bindings support a single stroke only

The core engine only accepts full Binding[] arrays. If you want command-to-key config sugar, use commandBindings(map) from @opentui/keymap/extras to build the array before calling registerLayer().

Binding reserves key, cmd, event, preventDefault and fallthrough. All other fields are available to binding-field compilers.

Use attr(...) in a binding-field compiler when a custom binding field should appear in query metadata such as ActiveBinding.attrs or ActiveKey.bindingAttrs.

Binding field defaults:

Field Default Description
event "press" Use "release" for single-stroke release bindings
preventDefault true Calls event.preventDefault() and event.stopPropagation() after a match
fallthrough false When true, later matching bindings in the same dispatch chain still run after this command

Release bindings dispatch on key release, but they do not appear in getActiveKeys() because active-key discovery is based on press/pending-sequence state.

The shared default parser understands single-stroke named keys, modifier chords, literal punctuation, " " for space, "+" as a literal plus key, <token> aliases, and {pattern} runtime captures. Spaced Emacs-style sequences such as ctrl+x ctrl+s are not part of the default parser.

Token and pattern registration names are syntax-free. The default parser maps <leader> to the token named "leader" and {count} to the sequence pattern named "count". Parser-preserved display keeps the original syntax for UIs, while the engine stores semantic names.

Unknown tokens skip the expanded binding until the token is registered. This avoids unsafe fallback shortcuts: <leader>q without a registered leader token is inactive, not equivalent to q. If an expander splits a config string into alternatives, only the alternatives containing unresolved tokens are skipped.

Sequence patterns can capture repeated runtime input and pass finalized values to command payloads:

Pattern field Description
name Semantic pattern name, also the default command payload key
display Optional parser-facing display override for structural graph/help UI
payloadKey Optional override when the command payload field should differ from name
min / max Optional capture limits. Defaults to at least one input and no practical maximum
match Runtime event predicate that returns a captured value/display or undefined for no match
finalize Optional conversion from captured values to the final payload value, e.g. digits to a number

Patterns with the default unbounded max must be followed by a concrete continuation, such as {count}j; terminal unbounded patterns are rejected.

State and discovery

Method Description
setData(name, value) Write runtime data
getData(name) Read runtime data
hasPendingSequence() true when a multi-stroke prefix is active
getPendingSequence() Current pending sequence as KeySequencePart[]
clearPendingSequence() Clears the pending sequence
popPendingSequence() Removes the last pending stroke. Returns true when a stroke was removed
getActiveKeys(options?) Returns the currently reachable keys for the current focus and pending state
parseKeySequence(key) Parses a KeyLike with the current parser/token/pattern environment
formatKey(key, options?) Parses and stringifies a KeyLike with the current environment
createKeyMatcher(key) Parses one KeyLike and returns a single-stroke predicate for any KeyStringifyInput. It does not match multi-stroke sequences
getHostMetadata() Returns the host metadata used by addons such as registerModBindings()

parseKeySequence() also follows the fail-closed token rule: a sequence containing an unknown token returns an empty sequence and emits the same unknown-token warning as binding compilation. Register the token first when parsing tokenized strings for display.

getActiveKeys() accepts ActiveKeyOptions:

Option Default Description
includeBindings false Include per-key bindings arrays with full binding views
includeMetadata false Include top-level bindingAttrs and commandAttrs on active keys

ActiveKey shape:

Field Type Description
stroke NormalizedKeyStroke Normalized stroke (name plus modifier flags)
display string Display string for the stroke, including the preserved token form when one was used
tokenName string? Token name when this key resolved through a registered token such as leader
continues boolean true when at least one binding continues the sequence past this key
command BindingCommand? Command at this exact sequence, if any
bindings ActiveBinding[]? Per-key bindings; populated only when includeBindings: true
bindingAttrs Readonly<Attributes>? Compiled binding attrs for the chosen binding; populated only when includeMetadata: true
commandAttrs Readonly<Attributes>? Compiled command attrs for the chosen binding; populated only when includeMetadata: true

ActiveBinding shape:

Field Type Description
sequence KeySequencePart[] Full key sequence that triggers the binding
event "press" | "release" Event the binding fires on
preventDefault boolean Resolved preventDefault after applying the default
fallthrough boolean Resolved fallthrough after applying the default
command BindingCommand? Command name or inline handler attached to the binding
attrs Readonly<Attributes>? Compiled binding-field attrs
commandAttrs Readonly<Attributes>? Compiled command-field attrs for the bound command

Field and metadata model

Custom fields are raw config. Compiled metadata is published by field compilers with attr(...).

Surface Custom fields Metadata output
Layer Extra layer properties feed layer-field compilers and binding pipeline GraphLayer.attrs in graph snapshots
Binding Extra binding properties feed binding-field compilers ActiveBinding.attrs, ActiveKey.bindingAttrs
Command Extra command properties feed command-field compilers commandAttrs on active keys and bindings

Field compilers can validate and normalize values before publishing attrs. They can also keep behavior-only fields out of public metadata.

keymap.registerLayerFields({
  name(value, ctx) {
    ctx.attr("name", String(value).trim())
  },
})

keymap.registerCommandFields({
  enabled(value, ctx) {
    ctx.activeWhen(() => value === true)
  },
  title(value, ctx) {
    ctx.attr("title", String(value).trim())
  },
})

In that example, layer name exposes metadata for graph/debug UIs. Command enabled controls availability but exposes no metadata, while command title exposes normalized metadata for command palettes and help UIs.

Use stringifyKeyStroke() or stringifyKeySequence() for display. Canonical formatting renders return as enter; preferDisplay: true keeps preserved token displays such as <leader>. Both are exported from @opentui/keymap:

import { stringifyKeyStroke, stringifyKeySequence } from "@opentui/keymap"

Commands and queries

Commands reserve only name and run. Other top-level properties are custom command fields.

keymap.registerLayer({
  commands: [
    {
      name: "save-file",
      namespace: "file",
      title: "Save File",
      run() {},
    },
  ],
})

// getCommands()[0] is the same command object:
// { name: "save-file", namespace: "file", title: "Save File", run }

Command field compilers can publish metadata, add requirements or attach runtime matchers. Unknown command fields stay on the command object; they do not warn just because no compiler is registered.

Return { ok: false, reason: "invalid-args" } from run() when a command wants to reject with a specific programmatic result.

Command queries search and filter across top-level command fields and compiled command metadata. The namespace shortcut reads the top-level namespace field.

Execution

Method Description
runCommand(cmd, options?) Runs a command against the registered command chain plus command resolvers
dispatchCommand(cmd, options?) Runs a command through the active dispatch chain for the supplied/current focus

Pure extra exported from @opentui/keymap/extras:

Helper Description
commandBindings(map) Converts a command-to-key object map into Binding[], trimming command keys before emitting them
createBindingLookup(config) Resolves flat command config into command lookups and lazy cached gathered binding groups
formatKeySequence(parts, opts) Formats parsed key sequences with optional token, key-name and modifier aliases
formatCommandBindings(...) Formats command binding lists, reusing formatKeySequence() and deduping by default

commandBindings() accepts optional onWarning and onError callbacks. onWarning reports trimmed-command overrides such as " save-file " and "save-file"; invalid key values are skipped by default, and onError lets callers observe them or throw if they want strict behavior.

formatKeySequence() and formatCommandBindings() are stateless display helpers for parsed keymap data. They do not read runtime state or host config. Use tokenDisplay, keyNameAliases and modifierAliases when your app wants presentation-specific labels such as "alt" instead of "meta" or "pgup" instead of "pageup".

Example:

import { commandBindings } from "@opentui/keymap/extras"

const bindings = commandBindings({
  "  save-file  ": "ctrl+s",
  ":write session.log": "<leader>s",
})

// => [
//      { key: "ctrl+s", cmd: "save-file" },
//      { key: "<leader>s", cmd: ":write session.log" },
//    ]

keymap.registerLayer({ bindings })
import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"

formatKeySequence(keymap.parseKeySequence("<leader>s"), {
  tokenDisplay: { leader: "space" },
  modifierAliases: { meta: "alt" },
  keyNameAliases: { pageup: "pgup", pagedown: "pgdn", delete: "del" },
})

formatCommandBindings(keymap.getCommandBindings({ visibility: "registered", commands: ["save-file"] }).get("save-file"))

createBindingLookup() is an opinionated config-to-keymap transformation for flat command config. Keys are command names and become binding.cmd. Values can be false, "none", a key, a full binding object, or arrays of keys/binding objects. false, "none", and empty arrays emit no bindings.

It deliberately keeps command identity separate from UI/layer grouping. Which bindings a component needs is a use-site concern, not part of the command name, so components gather named binding groups from plain command names when registering layers.

Use commandMap when user-facing config names should differ from internal command names. Mapping happens when the lookup is created or updated; runtime lookups use internal command names.

import { createBindingLookup } from "@opentui/keymap/extras"

const bindings = createBindingLookup(
  {
    show_palette: "ctrl+p",
    exit_app: ["ctrl+c", "<leader>q"],
    debug_app: "none",
    paste_prompt: { key: "ctrl+v", preventDefault: false },
    prompt_history_previous: "up",
    prompt_history_next: "down",
  },
  {
    bindingDefaults({ binding }) {
      if (binding.group !== undefined) return
      return { group: "App" }
    },
    commandMap: {
      show_palette: "app.palette.show",
      exit_app: "app.exit",
    },
  },
)

keymap.registerLayer({ bindings: bindings.gather("app", ["app.palette.show", "app.exit"]) })
bindings.get("app.exit")
bindings.has("app.exit")
bindings.pick("app", ["app.exit"])
bindings.omit("app", ["app.palette.show"])
bindings.invalidate("app")

The lookup exposes these views:

API Description
bindings All enabled bindings, preserving config order
get(command) Bindings for one exact command name, or an empty array when the command is missing/disabled
has(command) Whether the exact internal command has enabled bindings
gather(name, commands) Builds and caches a named binding group on first use; later calls with the same name return the cache
pick(name, commands) Bindings from an existing gathered group in caller command order; missing groups or commands are skipped
omit(name, commands) All gathered bindings except matching string-command bindings, preserving group order
invalidate(name?) Clears one gathered group, or all gathered groups when name is omitted
update(config?) Rebuilds command lookups from new or current config and clears gathered groups

Use bindingDefaults to add app-level fields to every emitted binding without mutating the original config. Defaults are spread before the resolved binding, so explicit binding fields win. Command names are exact and are not trimmed. get(), gather(), pick() and omit() return cached binding arrays directly when possible instead of defensive copies.

Tokens such as leader are still registered through the normal keymap token APIs and referenced by parser syntax such as <leader>.

dispatchCommand() respects current activation and can return inactive or disabled when the command exists but is not currently dispatchable. runCommand() walks the registered chain directly, so it can execute command-only layers programmatically even when they do not have an active binding.

RunCommandResult is { ok: true, command? } on success or { ok: false, reason, command? } on failure. Reject reasons:

Reason Meaning
not-found No registered command, resolver, or chain entry matched the supplied name
inactive The command exists but no candidate is active for the supplied/current focus (dispatchCommand only)
disabled All active candidates are gated off by enabled or other command-field matchers
invalid-args A resolver-provided rejection such as Ex-command argument validation
rejected Every candidate’s handler returned synchronous false
error A resolver, runtime matcher, or handler threw synchronously

command is included on rejected results when includeCommand: true is set and the keymap could resolve a command for the name.

Command handlers may be sync or async. A synchronous false rejects the candidate and lets lower-precedence handlers continue. A handler may also return an explicit RunCommandResult, such as { ok: false, reason: "invalid-args" }. A returned promise is treated as handled immediately; async rejection is reported through the error diagnostic event.

RunCommandOptions:

Option Description
event Event object passed into the command. Defaults to host.createCommandEvent()
focused Focus context used for active-layer resolution
target Explicit ctx.target override
includeCommand When true, successful and rejected results include the resolved command object
payload Per-invocation payload exposed as ctx.payload

Queries

Method Description
getCommands(query?) Returns command objects
getCommandEntries(query?) Returns command objects plus bindings for command discovery UIs
getCommandBindings(query) Returns bindings grouped by requested command name for known-command label UIs

CommandQuery fields:

Field Description
visibility "reachable" (default), "active", or "registered"
focused Override the focus context used by the query
namespace Filter by one or more namespace values
search Text search over command names by default
searchIn Additional command fields or compiled metadata to search
filter Object or predicate filter against command fields and metadata
  • reachable dedupes by command name using the current dispatch winner.
  • active keeps every active candidate in precedence order, including same-name duplicates.
  • registered ignores focus and returns all registered commands.

Use getCommandEntries() for command palettes, searchable help screens and other discovery surfaces that need commands plus their bindings. It runs the full CommandQuery and attaches matching bindings, so it is the heavier query.

Use getCommandBindings() when a UI already knows the commands it wants to label and only needs their bindings:

const bindingsByCommand = keymap.getCommandBindings({
  visibility: "registered",
  commands: ["file.save", "app.quit"],
})

const saveBindings = bindingsByCommand.get("file.save") ?? []

The returned map preserves the requested command order and includes every requested command with [] when no matching bindings exist. Formatting remains app-owned; each returned ActiveBinding includes its parsed sequence.

CommandBindingsQuery fields:

Field Description
commands Command names to include as map keys
visibility "reachable" (default), "active", or "registered"
focused Override the focus context used by the query

Graph snapshots

getGraphSnapshot(keymap, options?) from @opentui/keymap/extras/graph returns a diagnostic projection for graph visualizers and debug UIs. It includes layers, commands, bindings, sequence nodes, active keys and the current pending sequence.

Layer custom fields remain available as raw GraphLayer.fields; compiled layer metadata published with ctx.attr(...) appears as GraphLayer.attrs. Use attrs for stable visualization metadata such as layer display names.

import { getGraphSnapshot } from "@opentui/keymap/extras/graph"

keymap.registerLayerFields({
  name(value, ctx) {
    ctx.attr("name", String(value).trim())
  },
})

keymap.registerLayer({
  name: "Global",
  bindings: [{ key: "?", cmd: "toggle-help" }],
})

const snapshot = getGraphSnapshot(keymap)
snapshot.layers[0]?.attrs?.name // "Global"

Events and intercepts

keymap.on(...):

Event name Payload Description
state void Batched “derived state may have changed” signal
pendingSequence KeySequencePart[] Synchronous pending-sequence updates, including clear
dispatch DispatchEvent Sequence and binding execution trace events
warning WarningEvent Validation or analyzer warning
error ErrorEvent Registration, query, resolver, or listener error

DispatchEvent exposes phase, event, focused, sequence and optional layer, binding and command. Phases are sequence-start, sequence-advance, sequence-clear, binding-execute and binding-reject.

Diagnostic events are not batched through state; they are emitted when the warning or error is reported.

WarningEvent and ErrorEvent payloads:

Payload type Fields
WarningEvent code, message, warning
ErrorEvent code, message, error

Diagnostic behavior:

  • If no warning listener is registered, the keymap falls back to console.warn(...) for warnings.
  • If no error listener is registered, the keymap falls back to console.error(...) for errors.
  • Console fallback is per event type. Registering a warning listener suppresses warning console output but does not affect error, and vice versa.
  • Console fallback prefixes the message with [code]. If the underlying cause is an Error, it is passed as the second console argument.
  • Throwing warning or error listeners do not stop remaining diagnostic listeners from running, and they do not recursively emit another keymap error event.
  • warning commonly covers analyzer and validation warnings such as unknown fields, tokens or sequence patterns.
  • error covers more than registration failures: it also reports resolver failures, command-query failures, runtime matcher failures, and thrown state, pendingSequence or dispatch listeners.

keymap.intercept(...):

Form Description
intercept("key", fn) Runs before binding dispatch. Context exposes event, setData, getData and consume()
intercept("key:after", fn) Runs after binding dispatch. Context exposes handled, reason, sequence, event, setData, getData and consume()
intercept("raw", fn) Runs on raw host input before key parsing. Context exposes sequence and stop()

Intercept options:

Option Applies to Description
priority all Higher numbers run first
release key forms Listen to release events instead of press

key:after listeners are delivered once per key event that reaches the keymap listener, including no-match, rejected-binding, pending-sequence, and pre-intercept-consumed outcomes. Delivery is not gated by preventDefault() or stopPropagation(); those methods only affect host propagation/default behavior. The after context is only constructed when a matching key:after listener is registered.

Extension points

API Use for
registerToken(token) Define named single-stroke aliases such as leader
registerSequencePattern(pattern) Define named runtime sequence captures such as count
registerLayerFields(fields) Add custom layer fields with require(...), attr(...), activeWhen(...)
registerBindingFields(fields) Add custom binding fields with require(...), attr(...), activeWhen(...)
registerCommandFields(fields) Add custom command fields with require(...), attr(...), activeWhen(...)
prepend/appendBindingParser(parser) Add new key string syntaxes
prepend/appendBindingExpander(expander) Expand one key string into expansion objects, optionally preserving displays
prepend/appendBindingTransformer(fn) Rewrite parsed bindings or add derived bindings
prepend/appendCommandTransformer(fn) Rewrite registered commands or add derived commands before command fields
prepend/appendCommandResolver(resolver) Resolve string commands dynamically
prepend/appendLayerAnalyzer(analyzer) Emit warnings or errors while layers compile
prepend/appendEventMatchResolver(fn) Add alternate event-to-stroke matching
prepend/appendDisambiguationResolver(fn) Choose exact-vs-prefix behavior for ambiguous sequences
acquireResource(symbol, setup) Share ref-counted setup/teardown across multiple addon registrations

Every registration method above returns a disposer. The matching clear*() methods remove all registrations for that stage.

Disambiguation

Ambiguity happens when the same sequence is both an exact command and a prefix of a longer binding, for example g and gg.

Same-layer exact/prefix ambiguity is only allowed when at least one disambiguation resolver is registered. Without one, registering both g and gg in the same layer emits a compile error and keeps the valid earlier bindings.

keymap.appendDisambiguationResolver((ctx) => {
  if (ctx.sequence.length === 1) {
    return ctx.continueSequence()
  }

  return ctx.runExact()
})

Resolver context exposes:

Member Description
event Current key event without mutation methods
focused Current focused target
sequence / stroke Current pending sequence and latest stroke
exact Exact bindings at the current sequence
continuations Reachable continuation keys
getData / setData Runtime data access
runExact() Dispatch the exact binding now
continueSequence() Keep the prefix pending
clear() Clear the pending sequence and consume the key
defer(handler) Start cancellable async disambiguation work

Disambiguation resolvers must return synchronously. For async behavior, use ctx.defer(...). If no resolver decides, the engine falls back to prefix handling and emits a warning.

defer(handler) invokes handler with a KeyDeferredDisambiguationContext:

Member Description
signal AbortSignal aborted when a new key, focus change, or clearPendingSequence() cancels the deferred work
sequence Pending sequence captured when defer was called
focused Focused target captured when defer was called
sleep(ms) Resolves to true if the timeout elapsed, false if signal aborted first
runExact() Decision: dispatch the captured exact binding
continueSequence() Decision: keep the prefix pending
clear() Decision: clear the pending sequence

The handler may return a decision, a Promise<decision>, or nothing (no-op). Returning nothing after the signal aborted is the canonical cancel path.

Constraints

  • Runtime/data re-entry is supported during dispatch. Command handlers, intercepts and pending-sequence listeners can read and write runtime data.
  • Structural re-entry is not supported during dispatch. Do not register or unregister layers, parsers, resolvers, tokens, or similar environment-shaping state while a dispatch is in flight.
  • After the host is destroyed, host-backed reads such as getActiveKeys() are unavailable. Metadata reads such as getCommands() and runtime data access remain usable.