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"

const keymap = new Keymap(host as KeymapHost<object>)

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.

MemberRequiredDescription
metadatayesPlatform, primary shortcut modifier and modifier capability metadata
rootTargetyesRoot target for the host hierarchy
isDestroyedyesHost lifetime flag
getFocusedTarget()yesReturns the currently focused target or null
getParentTarget(target)yesParent traversal used for focus-within matching
isTargetDestroyed(target)yesTarget liveness check
onKeyPress(listener)yesSubscribe to press events
onKeyRelease(listener)yesSubscribe to release events
onFocusChange(listener)yesSubscribe to focus changes
onTargetDestroy(target, fn)yesSubscribe to target disposal/removal
createCommandEvent()yesCreates the synthetic event used by runCommand() and dispatchCommand()
onDestroy(listener)noOptional host-destroy notification
onRawInput(listener)noOptional raw input hook, used before host key parsing. The listener returns true when it consumed the sequence

Host key events must satisfy KeymapEvent:

MemberDescription
nameNormalized key name
ctrl / shift / metaModifier state
super / hyperOptional 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
propagationStoppedtrue 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 },
  ],
})
FieldDescription
targetOptional local target. Omit it for a global layer
targetMode"focus-within" or "focus". Defaults to "focus-within" when target is present
priorityHigher numbers win. Within the same priority, newer layers win
bindingsBinding[]
commandsNamed command definitions registered with the same layer
extra fieldsCustom 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

FormExampleNotes
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:

FieldDefaultDescription
event"press"Use "release" for single-stroke release bindings
preventDefaulttrueCalls event.preventDefault() and event.stopPropagation() after a match
fallthroughfalseWhen 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.

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

Pattern fieldDescription
nameSemantic pattern name, also the default command payload key
displayOptional parser-facing display override for structural graph/help UI
payloadKeyOptional override when the command payload field should differ from name
min / maxOptional capture limits. Defaults to at least one input and no practical maximum
matchRuntime event predicate that returns a captured value/display or undefined for no match
finalizeOptional 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

MethodDescription
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()

getActiveKeys() accepts ActiveKeyOptions:

OptionDefaultDescription
includeBindingsfalseInclude per-key bindings arrays with full binding views
includeMetadatafalseInclude top-level bindingAttrs and commandAttrs on active keys

ActiveKey shape:

FieldTypeDescription
strokeNormalizedKeyStrokeNormalized stroke (name plus modifier flags)
displaystringDisplay string for the stroke, including the preserved token form when one was used
tokenNamestring?Token name when this key resolved through a registered token such as leader
continuesbooleantrue when at least one binding continues the sequence past this key
commandBindingCommand?Command at this exact sequence, if any
bindingsActiveBinding[]?Per-key bindings; populated only when includeBindings: true
bindingAttrsReadonly<Attributes>?Compiled binding attrs for the chosen binding; populated only when includeMetadata: true
commandAttrsReadonly<Attributes>?Compiled command attrs for the chosen binding; populated only when includeMetadata: true

ActiveBinding shape:

FieldTypeDescription
sequenceKeySequencePart[]Full key sequence that triggers the binding
event"press" | "release"Event the binding fires on
preventDefaultbooleanResolved preventDefault after applying the default
fallthroughbooleanResolved fallthrough after applying the default
commandBindingCommand?Command name or inline handler attached to the binding
attrsReadonly<Attributes>?Compiled binding-field attrs
commandAttrsReadonly<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(...).

SurfaceCustom fieldsMetadata output
LayerExtra layer properties feed layer-field compilers and binding pipelineGraphLayer.attrs in graph snapshots
BindingExtra binding properties feed binding-field compilersActiveBinding.attrs, ActiveKey.bindingAttrs
CommandExtra command properties feed command-field compilerscommandAttrs 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

MethodDescription
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:

HelperDescription
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:

APIDescription
bindingsAll 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:

ReasonMeaning
not-foundNo registered command, resolver, or chain entry matched the supplied name
inactiveThe command exists but no candidate is active for the supplied/current focus (dispatchCommand only)
disabledAll active candidates are gated off by enabled or other command-field matchers
invalid-argsA resolver-provided rejection such as Ex-command argument validation
rejectedEvery candidate’s handler returned synchronous false
errorA 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:

OptionDescription
eventEvent object passed into the command. Defaults to host.createCommandEvent()
focusedFocus context used for active-layer resolution
targetExplicit ctx.target override
includeCommandWhen true, successful and rejected results include the resolved command object
payloadPer-invocation payload exposed as ctx.payload

Queries

MethodDescription
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:

FieldDescription
visibility"reachable" (default), "active", or "registered"
focusedOverride the focus context used by the query
namespaceFilter by one or more namespace values
searchText search over command names by default
searchInAdditional command fields or compiled metadata to search
filterObject 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:

FieldDescription
commandsCommand names to include as map keys
visibility"reachable" (default), "active", or "registered"
focusedOverride 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 namePayloadDescription
statevoidBatched “derived state may have changed” signal
pendingSequenceKeySequencePart[]Synchronous pending-sequence updates, including clear
dispatchDispatchEventSequence and binding execution trace events
warningWarningEventValidation or analyzer warning
errorErrorEventRegistration, 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 typeFields
WarningEventcode, message, warning
ErrorEventcode, 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(...):

FormDescription
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:

OptionApplies toDescription
priorityallHigher numbers run first
releasekey formsListen 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

APIUse 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:

MemberDescription
eventCurrent key event without mutation methods
focusedCurrent focused target
sequence / strokeCurrent pending sequence and latest stroke
exactExact bindings at the current sequence
continuationsReachable continuation keys
getData / setDataRuntime 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:

MemberDescription
signalAbortSignal aborted when a new key, focus change, or clearPendingSequence() cancels the deferred work
sequencePending sequence captured when defer was called
focusedFocused 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.