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.
| 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
prioritywins, then newer layers win. - Returning
falsefrom a command rejects that candidate so lower-precedence handlers can continue. fallthroughandpreventDefaultare independent.fallthroughcontinues inside the keymap;preventDefaultcontrols 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.
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() |
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 |
reachablededupes by command name using the current dispatch winner.activekeeps every active candidate in precedence order, including same-name duplicates.registeredignores 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
warninglistener is registered, the keymap falls back toconsole.warn(...)for warnings. - If no
errorlistener is registered, the keymap falls back toconsole.error(...)for errors. - Console fallback is per event type. Registering a
warninglistener suppresses warning console output but does not affecterror, and vice versa. - Console fallback prefixes the message with
[code]. If the underlying cause is anError, it is passed as the second console argument. - Throwing
warningorerrorlisteners do not stop remaining diagnostic listeners from running, and they do not recursively emit another keymap error event. warningcommonly covers analyzer and validation warnings such as unknown fields, tokens or sequence patterns.errorcovers more than registration failures: it also reports resolver failures, command-query failures, runtime matcher failures, and thrownstate,pendingSequenceordispatchlisteners.
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 asgetCommands()and runtime data access remain usable.