opentui

Build terminal UIs with OpenTUI. Covers Core, frameworks, components, application APIs, testing, extensions, integrations, deployment, and public API lookup.

Install
npx skills add 'https://github.com/anomalyco/opentui/tree/main/packages/web/src/content'
Download bundle ↓
main · ac753b4Scanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

docs/keymap/core.mdx

docs/keymap/core.mdxBrowse 89 files
View on GitHub
← Back to SKILL.md

title: Core keymap API description: Configure and query the host-independent Keymap engine

Core keymap API

@opentui/keymap exports the host-independent engine, key stringifiers, and shared types. This page defines its registration, dispatch, query, event, and extension behavior.

The Keymap hosts page owns the KeymapHost contract and built-in adapters.

Construct the engine

Pass a live host to Keymap:

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

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

The constructor throws if the host is already destroyed. It subscribes to key press, key release, and focus events. It also subscribes to raw input and host destruction when the host supplies those capabilities.

A bare keymap has no string parser or event-match resolver. Object-form keys can compile without a parser, but host events cannot dispatch until you register an event-match resolver. Most apps use a default host factory instead.

Engine methods

Layers, data, and queries

MethodResult
registerLayer(layer)Register commands and key bindings, then return a disposer
setData(name, value)Set runtime data, or delete the entry when value is undefined
getData(name)Read runtime data
getHostMetadata()Read the host platform and modifier capabilities
hasPendingSequence()Test whether a key sequence is pending
getPendingSequence()Read the pending KeySequencePart[]
clearPendingSequence()Clear the pending sequence
popPendingSequence()Remove the last pending stroke and report whether one existed
getActiveKeys(options?)Read the next reachable press keys
parseKeySequence(key)Parse a KeyLike with the current parser environment
formatKey(key, options?)Parse and stringify a KeyLike
createKeyMatcher(key)Create a predicate for one parsed stroke
getCommands(query?)Query command objects
getCommandEntries(query?)Query commands with their key bindings
getCommandBindings(query)Query key bindings for named commands
runCommand(cmd, options?)Run the registered command chain without activation checks
dispatchCommand(cmd, options?)Run the active command chain for a focus context

createKeyMatcher() accepts only one stroke. It returns false for null and undefined input.

Events, intercepts, and resources

MethodResult
on(name, listener)Subscribe to state, pendingSequence, dispatch, warning, or error
intercept("key", listener, options?)Run before press or release dispatch
intercept("key:after", listener, options?)Run after one dispatch outcome
intercept("raw", listener, options?)Run before host key parsing when the host supports raw input
acquireResource(symbol, setup)Share reference-counted setup and return a release function

Each subscription method returns a disposer.

Extension registration

StageRegister methodsClear method
Layer fieldsregisterLayerFields()None
Binding fieldsregisterBindingFields()None
Command fieldsregisterCommandFields()None
TokensregisterToken()Use each token disposer
Sequence patternsregisterSequencePattern()Use each pattern disposer
Layer key bindingsprependLayerBindingsTransformer(), appendLayerBindingsTransformer()clearLayerBindingsTransformers()
Key expansionprependBindingExpander(), appendBindingExpander()clearBindingExpanders()
Key parsingprependBindingParser(), appendBindingParser()clearBindingParsers()
Parsed key bindingsprependBindingTransformer(), appendBindingTransformer()clearBindingTransformers()
CommandsprependCommandTransformer(), appendCommandTransformer()clearCommandTransformers()
Command resolutionprependCommandResolver(), appendCommandResolver()clearCommandResolvers()
Layer diagnosticsprependLayerAnalyzer(), appendLayerAnalyzer()clearLayerAnalyzers()
Event matchingprependEventMatchResolver(), appendEventMatchResolver()clearEventMatchResolvers()
Sequence ambiguityprependDisambiguationResolver(), appendDisambiguationResolver()clearDisambiguationResolvers()

Every register, prepend, and append method returns a disposer. A clear*() method removes the complete stage. Use a clear method only when your code owns that stage.

Layers

registerLayer() accepts Layer<TTarget, TEvent>:

FieldDefaultMeaning
targetnoneLocal host target. Omit it for a global layer.
targetModefocus-within for a targeted layerUse focus for an exact focused-target match.
priority0Higher values run first.
bindings[]Readonly array of key binding records.
commands[]Readonly array of named commands.
Other fieldsnoneInputs for registered layer-field compilers and the binding pipeline.

targetMode without target is invalid. A targetless layer stays active for every focus context.

The engine sorts layers by descending priority. Newer layers run first when priorities match. A local layer gets no automatic priority over a global layer. Within one layer, key bindings keep source order.

A command or inline handler can return synchronous false to reject its candidate. Dispatch then tries the next matching command or key binding. fallthrough: true continues after a handled key binding.

The layer disposer removes the layer and its reactive subscriptions. Host target destruction does the same for a local layer. Removing a layer that owns the pending sequence clears that sequence.

The engine copies the layer's key binding records and object-form keys during registration. Without a command transformer, it keeps registered command objects by reference and returns them from getCommands().

Command names are trimmed during registration. Empty names and names with whitespace are invalid. The engine rejects duplicate command names in one layer, but different layers can register the same name.

Key bindings

A Binding reserves these fields:

FieldDefaultMeaning
keyrequiredA parser string or one KeyStrokeInput object.
cmdnoneA command name or inline CommandHandler.
eventpressUse release for a release key binding.
preventDefaulttrueCall both event prevention methods after a handled match.
fallthroughfalseContinue to later matching key bindings after a handled match.
Other fieldsnoneInputs for registered binding-field compilers.

preventDefault controls host delivery. fallthrough controls dispatch inside Keymap. These settings are independent.

Release key bindings support exactly one stroke. They dispatch from release events but do not appear in getActiveKeys(), which describes press and pending-sequence state.

The engine requires bindings to be an array of objects with a string or object key. Invalid entries produce an error diagnostic and do not register.

Default key syntax

The core has no fixed string syntax. registerDefaultKeys() from @opentui/keymap/addons installs the shared parser and the canonical event matcher.

The default parser accepts these forms:

FormExamplesMeaning
Literal key"x", "?", " ", "+"One literal stroke
Named key"return", "pageup", "f12"One named stroke
Modifier chord"ctrl+x", "ctrl+shift+s"One modified stroke
Concatenated sequence"dd", "g?"Several strokes without separators
Token"<leader>s"A registered single-stroke alias plus more input
Sequence pattern"{count}j"A runtime capture plus a concrete continuation
Object stroke{ name: "return", ctrl: true }One stroke that skips string parsing

Modifier prefixes are case-insensitive. The parser accepts ctrl or control, meta, alt, or option, and the single names shift, super, and hyper.

The named key set contains:

  • Direction and editing names: up, down, left, right, clear, escape, return, linefeed, enter, tab, backspace, delete, insert, home, end, pageup, pagedown, and space.
  • Punctuation names: lt, gt, plus, minus, equal, comma, period, slash, backslash, semicolon, quote, backquote, leftbracket, and rightbracket.
  • Lock and system names: capslock, numlock, scrolllock, printscreen, pause, menu, and apps.
  • Function names with one or two digits after f.
  • Keypad names from kp0 through kp9, plus kpdecimal, kpdivide, kpmultiply, kpminus, kpplus, kpenter, kpequal, kpseparator, kpleft, kpright, kpup, kpdown, kppageup, kppagedown, kphome, kpend, kpinsert, and kpdelete.
  • Media names: mediaplay, mediapause, mediaplaypause, mediareverse, mediastop, mediafastforward, mediarewind, medianext, mediaprev, mediarecord, volumedown, volumeup, and mute.
  • Left and right modifier names for shift, ctrl, alt, super, hyper, and meta, plus iso_level3_shift and iso_level5_shift.
  • Standalone modifier names: option, alt, meta, super, hyper, control, ctrl, and shift.

The default parser does not accept spaced Emacs sequences such as ctrl+x ctrl+s. Install registerEmacsBindings() for that syntax.

An unknown token or pattern makes only that expanded key binding inactive. The parser emits a warning and never falls back to the remaining literal keys. Registering or disposing a token or pattern recompiles affected layers.

parseKeySequence() follows the same fail-closed rule. It returns an empty sequence for an unresolved token or pattern.

Tokens and sequence patterns

registerToken({ name, key }) defines one semantic, delimiter-free name. The default parser maps <leader> to the token named leader. A token must resolve to one concrete stroke.

registerSequencePattern(pattern) defines a repeated runtime capture:

FieldDefaultMeaning
namerequiredSemantic name and default payload key.
display{name}Structural display text for the parser.
payloadKeynameProperty name in the command payload.
min1Minimum matched strokes before a continuation can match.
maxNumber.MAX_SAFE_INTEGERMaximum matched strokes.
match(event)requiredReturn capture data or undefined.
finalize(values)one value or an array of valuesConvert captured values for the command payload.

min and max must be non-negative integers, and max must be at least min. A variable-length pattern with max !== min must have a concrete continuation. A fixed-length pattern with min === max can end a key sequence.

keymap.registerSequencePattern({
  name: "count",
  match(event) {
    return /^\d$/.test(event.name) ? { value: event.name, display: event.name } : undefined
  },
  finalize(values) {
    return Number(values.join(""))
  },
})

keymap.registerLayer({
  commands: [
    {
      name: "cursor.down",
      run({ payload }) {
        const count = (payload as { count?: number } | undefined)?.count ?? 1
        console.log(count)
      },
    },
  ],
  bindings: [
    { key: "j", cmd: "cursor.down" },
    { key: "{count}j", cmd: "cursor.down" },
  ],
})

Pattern captures add properties to ctx.payload. A count pattern therefore produces payload.count unless it sets another payloadKey.

Pending sequence input consumes the host event while the sequence remains valid. A mismatch clears the sequence and does not retry that key at the root. Focus changes, relevant layer disposal, and failed runtime conditions also clear it.

Fields, metadata, and runtime data

Custom fields are configuration input. Field compilers can add requirements, runtime matchers, and query metadata.

RegistrationApplies toMetadata destination
registerLayerFields()Extra layer fieldsGraphLayer.attrs in graph snapshots
registerBindingFields()Extra key binding fieldsActiveBinding.attrs and ActiveKey.bindingAttrs
registerCommandFields()Extra command fieldsCommand projections and commandAttrs

Each compiler can call require(name, value), activeWhen(matcher), and attr(name, value). require() uses Object.is() against keymap runtime data. activeWhen() accepts a function or ReactiveMatcher. attr() publishes metadata and does not change activation.

See Custom keymap addons for the callback contracts and field pipeline.

Unknown layer and key binding fields emit warnings. The engine ignores them for activation and metadata. Unknown command fields remain on the command object without a warning.

setData() stores shared runtime state for requirements, matchers, commands, and intercepts. Setting an entry to undefined deletes it. A changed value invalidates queries and clears a pending sequence that is no longer reachable.

Active keys

getActiveKeys() returns the next reachable press strokes for the current focus and pending state. It does not return every registered key binding.

ActiveKeyOptions has two flags, both false by default:

OptionEffect
includeBindingsAdd the selected bindings array to each key.
includeMetadataAdd selected bindingAttrs and commandAttrs.

ActiveKey contains:

FieldMeaning
strokeNormalized next stroke.
displayDisplay text, including preserved token text when unambiguous.
tokenNameToken name when all selected paths use the same token.
continuesWhether the stroke can continue a sequence.
commandCommand at this exact sequence, when one is reachable.
bindingsSelected key bindings when includeBindings is true.
bindingAttrsMetadata from the first selected key binding when requested.
commandAttrsMetadata from the selected command when requested.

Each ActiveBinding contains sequence, event, preventDefault, fallthrough, optional command, optional attrs, and optional commandAttrs.

Commands

A command reserves name and run. Other top-level properties are custom command fields.

CommandContext contains:

FieldMeaning
keymapCurrent Keymap instance.
eventHost event or synthetic command event.
focusedFocus context for this execution.
targetLayer target or explicit non-null override.
dataFrozen top-level snapshot of current runtime data.
commandResolved named command when one exists.
inputOriginal or resolver-rewritten command input.
payloadInvocation or sequence payload.

runCommand() uses all registered commands and ignores layer activation and command conditions. dispatchCommand() uses the active command chain for the selected focus context.

Both methods accept RunCommandOptions:

OptionMeaning
eventEvent passed to the command. The host creates one when omitted.
focusedNon-null focus override. Omitted and null values currently use host focus.
targetNon-null command target override. Omitted and null values can use the command layer target.
includeCommandInclude the resolved command object in the result when available.
payloadValue exposed as ctx.payload.

The return type is RunCommandResult:

ResultMeaning
{ ok: true, command? }A command handled the call.
not-foundNo command or resolver matched.
inactiveThe command exists, but no candidate layer is active.
disabledActive candidates fail layer or command conditions.
invalid-argsInput normalization or a command rejected its arguments.
rejectedEvery candidate returned synchronous false.
errorResolution, matching, or execution threw synchronously.

A command can return its own RunCommandResult. A synchronous false tries the next command in the chain. A returned promise counts as handled immediately. A later rejection emits an error diagnostic.

Command queries

MethodUse
getCommands(query?)Read command objects.
getCommandEntries(query?)Read commands with matching key bindings.
getCommandBindings(query)Read key bindings for a known command-name list.

CommandQuery supports:

FieldDefaultMeaning
visibilityreachableUse reachable, active, or registered.
focusedCurrent host focusOverride focus. An explicit null selects no focused target.
namespaceallMatch one namespace or an array of namespaces.
searchnoneCase-insensitive substring search.
searchIn["name"]Replace the search field list when non-empty.
filternoneMatch raw fields and compiled attrs with an object or predicate.
limitnoneReturn at most the floored positive finite count. Other supplied values return no results.

reachable keeps the current winner for each command name. active keeps all active candidates in precedence order. registered ignores focus and conditions.

getCommandEntries() applies the complete command query before it attaches key bindings. Use it for a command palette that needs both records.

getCommandBindings() accepts only commands, visibility, and focused. Its map preserves the requested command order and includes an empty array for each missing command.

Events

keymap.on() supports these events:

NamePayloadTiming
statevoidBatched signal that derived state can have changed.
pendingSequencereadonly KeySequencePart[]Synchronous update, including clear.
dispatchDispatchEventSequence and key binding trace event.
warningWarningEventValidation or analyzer warning.
errorErrorEventRegistration, query, callback, or execution error.

DispatchEvent.phase is sequence-start, sequence-advance, sequence-clear, binding-execute, or binding-reject. The event also contains event, focused, sequence, and optional layer, binding, and command.

WarningEvent contains code, message, and warning. ErrorEvent contains code, message, and error. Diagnostics are synchronous and are not part of the batched state event.

If an event type has no listener, warnings use console.warn() and errors use console.error(). Adding a listener suppresses console fallback only for that event type. Console fallback prefixes the message with [code] and passes an Error cause as a second argument. Throwing diagnostic listeners do not stop later listeners and do not emit recursive diagnostics.

Intercepts

Key intercepts run by descending priority. Earlier registrations run first when priorities match. priority defaults to 0. The release option defaults to false and applies only to key intercepts.

FormContext
intercept("key", fn)event, setData, getData, and consume()
intercept("key:after", fn)event, eventType, focused, handled, reason, sequence, pendingSequence, data methods, and consume()
intercept("raw", fn)sequence and stop()

consume() accepts preventDefault and stopPropagation. Both default to true. A pre-dispatch key intercept stops dispatch when it stops event propagation.

key:after runs once for every matching press or release listener after the keymap receives the event. Its reason is intercept-consumed, binding-handled, binding-rejected, no-match, sequence-pending, sequence-miss, or sequence-cleared.

Raw interception works only when the host supplies onRawInput(). Calling stop() makes the host raw listener return true.

Extension order

The key binding pipeline runs in this order when a layer registers:

  1. Layer key binding transformers rewrite the complete key binding array.
  2. Expanders turn one string into one or more strings.
  3. Parsers turn each string into sequence parts.
  4. Binding transformers rewrite parsed key bindings or add derived ones.
  5. Binding-field compilers add conditions and metadata.
  6. Layer analyzers inspect the compiled key binding records.
  7. The engine registers the layer and builds its sequence tree.

Command transformers run before command-field compilers. The layer key binding transformer receives the source layer. The engine then validates and snapshots extra layer fields before expansion and parsing. Expanders, parsers, and key binding transformers receive that read-only snapshot.

prepend*() entries run before append*() entries. Multiple prepend calls run newest first. Multiple append calls run oldest first.

Install compile-time extensions before their layers. Adding or removing a parser, transformer, field compiler, or analyzer does not recompile existing layers. Tokens and sequence patterns do recompile relevant key bindings. Adding the first disambiguation resolver and removing the last one also recompiles layers.

See Custom keymap addons for every callback context, return contract, and error rule.

Sequence ambiguity

An ambiguity exists when one sequence is both an exact command and a prefix, such as g and gg.

Without a disambiguation resolver, the compiler rejects a same-layer ambiguous key binding and keeps valid earlier key bindings. A resolver can run the exact command, keep the prefix pending, clear it, or start deferred work.

Resolvers return synchronously. Deferred handlers receive an AbortSignal and sleep(ms). A new press, focus change, or explicit sequence clear cancels pending deferred work.

If no resolver makes a decision, the engine warns and keeps the prefix pending. The shipped registerNeovimDisambiguation() resolver applies a timeout.

Display helpers

The root exports stringifyKeyStroke() and stringifyKeySequence().

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

Canonical output orders modifiers as ctrl, shift, meta, super, and hyper. It displays the key name return as enter. Sequence output uses no separator by default.

Set preferDisplay: true to retain parser display text such as <leader>. Set separator to place text between sequence parts.

keymap.formatKey() first uses the current parsers, tokens, and patterns. The root stringifiers only format data that is already parsed or normalized.

Root exports

The root runtime values are:

ExportPurpose
KeymapHost-independent engine class.
stringifyKeyStrokeFormat one key stroke.
stringifyKeySequenceFormat parsed sequence parts.
KEYMAP_EXTENSION_CONTEXTAccess the advanced engine extension context.

keymap[KEYMAP_EXTENSION_CONTEXT]() returns state, host, conditions, catalog, and activation services. This surface is for tightly coupled engine extensions. Normal addons should use the public registration methods.

The root type surface is grouped below. Advanced callback types appear only in the final two rows.

GroupTypes
Keys and patternsKeyLike, KeyMatch, KeyStrokeInput, NormalizedKeyStroke, KeySequencePart, KeyStringifyInput, StringifyOptions, KeyToken, ResolvedKeyToken, SequencePattern, SequencePatternMatch, ResolvedSequencePattern
Layers and key bindingsAttributes, Binding, Bindings, BindingCommand, BindingEvent, ParsedBinding, ActiveBinding, ActiveKey, ActiveKeyOptions, Layer, TargetMode
Commands and queriesCommand, CommandContext, CommandHandler, CommandResult, CommandEntry, CommandFilter, CommandQuery, CommandQueryValue, CommandBindingsQuery, RunCommandOptions, RunCommandResult, ParsedCommand
HostsKeymapEvent, KeymapHost, HostCapability, HostMetadata, HostModifier, HostPlatform
Events and interceptsEventData, Listener, Events, EventName, WarningEvent, ErrorEvent, DispatchBinding, DispatchEvent, DispatchLayer, DispatchPhase, Intercepts, InterceptName, KeyInputContext, KeyAfterInputContext, KeyAfterReason, RawInputContext, KeyInterceptOptions, RawInterceptOptions
FieldsReactiveMatcher, LayerFieldCompiler, LayerFieldContext, BindingFieldCompiler, BindingFieldContext, CommandFieldCompiler, CommandFieldContext
Advanced pipelineBindingParser, BindingParserContext, BindingParserResult, BindingExpansion, BindingExpander, BindingExpanderContext, BindingsValidationResult, LayerBindingsTransformer, LayerBindingsTransformerContext, BindingTransformer, BindingTransformerContext, CommandTransformer, CommandTransformerContext, CommandResolver, CommandResolverContext, LayerAnalysisContext, LayerBindingAnalysis, LayerAnalyzer, EventMatchResolver, EventMatchResolverContext
Advanced disambiguation and extensionKeyDisambiguationContext, KeyDisambiguationDecision, KeyDeferredDisambiguationContext, KeyDeferredDisambiguationDecision, KeyDeferredDisambiguationHandler, KeyDisambiguationResolver, KeymapExtensionContext, KeymapExtensionProvider

See the API and symbol index for the exhaustive cross-package index. See Package entry points for extras, graph, adapter, framework, testing, and runtime-module import paths.

Errors, cleanup, and limits

Most invalid registrations emit an error event and return a no-op disposer instead of throwing. Each successful registration disposer removes only its own entry and is safe after host destruction.

acquireResource() runs setup() for the first holder of a symbol. Later holders share it. The resource disposer runs after the last release or during host destruction. A failed setup is not retained.

The state event batches nested changes. The engine drops pending state notifications and emits an error after 1,000 feedback-loop iterations.

Runtime data and pending-sequence changes can occur during dispatch. Structural registration changes cannot. Do not add or remove layers, parsers, tokens, resolvers, or similar engine structure during dispatch.

After host destruction, host-backed reads such as getActiveKeys() throw. Runtime data and registered command metadata remain readable.

Use @opentui/keymap/testing for a fake host and diagnostic capture. See Testing for the broader OpenTUI test strategy.