The host framework's component reference type.
Create a new engine instance.
Builds the internal state tree: EngineState (path, layer/modal-layer
ids, modes, conditions, global keys/sequences, operation registries,
owner stack), LayerManager, PipelineManager (the built-in 9-stage
chain plus any custom processors), BindingService, OperationRegistry
and the CompositionEngine (initialized with defaultTimeout and
valueSchema).
The instance is meant to persist for the lifetime of the host component —
store it in a stable reference (e.g. useRef in React, a class field in
Vue/Svelte).
The host framework's component reference type.
Engine configuration.
normalizeKeyNames is required — the engine has no built-in default
so each framework must provide its own adapter.
const engine = new KeyboardEngine({
normalizeKeyNames: (input, key) => {
const k = key as Record<string, unknown>;
return [k.name ? String(k.name) : input];
},
isNormalChar: (key) => {
const k = key as Record<string, unknown>;
return !!(k.ctrl || k.meta || k.escape || k.tab || k.upArrow || k.downArrow);
},
modes: ['normal', 'insert'],
defaultMode: 'normal',
});
The composition engine for composing multi-key compound actions.
Cancel the current composition chain immediately.
Activate a focus target in a group that currently has no active focus.
Unlike focusSet — which replaces a group's active target — this
method only succeeds when the group has no active entry yet. It is
designed for lazy activation: register focus targets early, then call
activateFocusGroup to give a group its initial focus on demand without
overwriting focus that was already established.
Returns false (no-op) when the group already has an active target, or
when the owner, layer, group, or focus target is absent. Use
focusSet when you need to switch a group's active target
regardless of its current state.
The focus target id to activate.
OptionalgroupOrOptions: string | FocusSetOptions
Optional focus group name (or FocusSetOptions).
true if the target was activated, false if the group already
had an active target or the target/group/layer could not be found.
Re-activate a previously kicked built-in processor by removing it from the disabled list. The processor resumes normal operation on the next processKey call.
When a processor is actively processing events (i.e. not in the disabled
list), calling activeProcessor is a no-op that returns false.
The built-in processor ID to re-enable.
true if the processor was re-activated, false if it was
already active or the id was not found in the disabled list.
Register a named condition for when: "conditionId" references.
true if registered, false if the id already exists.
Register a mapping key entry. See CompositionEngine#addMapping.
Optionaloptions: Omit<MappingKeyEntry<TComponent>, "keys" | "target">true if registered, false if base is empty, any target
key is not registered, or an identical base already exists.
Register a mode name. Modes must be registered before use in
setMode, nextMode, or prevMode.
true if added, false if already registered.
Insert a processor into this instance's pipeline at a specified position.
Options (checked in order):
{ index: n } — insert at 0-based index{ before: "id" } / { after: "id" } — insert relative to a named processorOptionaloptions: { before?: string } | { after?: string } | { index?: number }Allow specific keys to pass through the modal barrier. By default the active modal consumes every key event — even unbound keys. Adding a key to the allow list releases it to lower pipeline stages.
This is the only mechanism by which keys can escape the modal barrier.
In the modal pipeline stage (stage 0), allowedKeys is checked before
any other processing: a matching key (whose when evaluates to true)
makes the modal processor return false, so the event continues to the
global/layer/page stages below. Keys released this way count as
unhandled ("miss") for useModalMissListener when nothing else
consumes them.
Optionaloptions: AllowModalOptionsA function that removes the allow entry, restoring the default behavior (the modal blocks the key again).
// Allow arrow keys through the modal so the underlying screen can still navigate
engine.allowModal(['up', 'down', 'left', 'right']);
// Allow escape only when a condition is met
engine.allowModal(['escape'], {
when: () => !isCriticalOperation,
});
// Focus-scoped allow
engine.allowModal(['enter'], { focusId: 'transferInput' });
Bind one or more keys to a handler on the current owner's layer.
Supports three calling conventions:
boundKeyboard(keys, handler, options?) — explicit keys and callbackboundKeyboard(keys, actionId, options?) — explicit keys, shortcut action by idboundKeyboard(actionId, options?) — uses the shortcut action's preset keysStorage follows the options: with elementId the binding is stored on
that element's keyboard data; with focusId on the named
FocusTarget.bindings array; with neither, on the page layer's bindings
array (or the element keyboard's bindings inside a layer/modal layer).
Element bindings are evaluated in the layer broadcast stage and the
modal stage; page-level bindings in the screen-stack stage, after global
keys and layer broadcast. Within a keyboard layer, focus-target bindings
are checked before layer-level bindings.
Options behavior:
once — auto-remove after the first invocation; the unbind happens
before the handler runstimes / observer — the handler fires after times presses; the
counter resets after the handler runs, and observer is called on
each press while counting (requires times)when — a condition function or a registered condition id
(see addCondition); the binding is skipped when it evaluates
to falsemode — restricts the binding to a specific mode; skipped when the
active mode doesn't matchstopsWorkingAfterLayerAppearing — page-level bindings only: when any
layer is present the page binding stops responding; has no effect
inside a layerOptionalmaybeOptions: BoundKeyboardOptionsAn unbind function. Removes the binding from the layer immediately; safe to call multiple times.
// Inline handler
const unbind = engine.boundKeyboard('return', (input, key) => {
console.log('submit');
});
// Via shortcut action
engine.boundKeyboard('ctrl+s', 'save');
// Via action preset keys
engine.boundKeyboard('confirm');
// With options — one-shot, focus-scoped, press-counted
engine.boundKeyboard('escape', handleCancel, {
once: true,
focusId: 'dialog',
times: 2,
when: () => isDirty,
mode: 'normal',
});
Register a multi-key sequence binding on the current owner's layer.
When the first key of a registered sequence is pressed, the layer enters
a pending state. Subsequent key presses are matched against the remaining
keys; when all match within the timeout the handler fires. A mismatched
key cancels the sequence (default) or is silently consumed
(exclusive: true). The when condition (callback or registered
condition id) is checked at each key press — if it returns false, the
pending sequence is cancelled.
Supports three calling conventions:
boundSequence(keys, handler, options?) — explicit keys and callbackboundSequence(keys, actionId, options?) — explicit keys, sequence
action by id (the action's callback is resolved at registration time)boundSequence(actionId, options?) — uses the action's preset keysThe sequence timeout defaults to 500 ms — the timer starts on the first key and resets on each match. Unbinding while a sequence is pending does not cancel it.
Throws if fewer than 2 keys are provided, if the first key conflicts
with a global sequence that has cover: false, or if observer is set
without times.
OptionalhandlerOrOptions: string | SequenceOptions | KeyHandlerOptionalmaybeOptions: SequenceOptionsAn unbind function.
// Explicit keys with handler
engine.boundSequence(['g', 'g'], () => {
scrollToTop();
});
// With timeout and exclusive mode
engine.boundSequence(['ctrl+w', 'q'], handleQuit, {
timeout: 1000,
exclusive: true,
mode: 'normal',
});
// Via sequence action
engine.defineSequenceAction([{
sequenceActionId: 'vim-goto-top',
action: () => scrollToTop(),
keys: ['g', 'g'],
timeout: 600,
}]);
engine.boundSequence('vim-goto-top');
// Via sequence action with explicit keys (the action's callback is used;
// the action's preset timeout acts as a default, overridable per call)
engine.boundSequence(['ctrl+g', 'g'], 'vim-goto-top', { timeout: 800 });
Number of completed sequences available for undo.
Build a snapshot of all mutable state needed to process a single key event through the pipeline.
Called by processKey once per key event. All values are read synchronously to produce a consistent frozen-in-time view.
The returned object is cast to PipelineContext because the engine's
generic TComponent may not match the legacy React.ComponentType in
the typed interface — this is a bridge point that will be resolved when
the pipeline types are fully generic.
Remove keyboard layers for screens that are no longer in the current path.
This is the cleanup side of the sync lifecycle: sync pushes
new state, these methods remove stale state from the previous render
cycle. Designed to be called in a post-render effect (e.g. useEffect
in React) so they compare against the state pushed by the most recent
sync.
Also clears any pending sequence timers on removed layers to prevent stale timeouts from firing after the layer is gone.
Remove element keyboards for modal layers that have been closed.
Only cleans keyboards whose modal-layer owner is no longer present in
the synced modalLayers state.
Remove element keyboards for layers that have been closed.
Only cleans keyboards whose layer owner is no longer present in the
synced layers state — pages and modal layers are left untouched.
Clear all registered composition keys.
Clear all buffered undo history.
Clear all registered sequence operations.
Clear all registered shortcut operations.
Check whether the current owner's layer has an active pending multi-key sequence (registered via boundSequence). Unlike thereGlobalQueueWaiting, this only checks the layer belonging to the current owner — the page, layer, or modal layer that owns the active keyboard data. Use this to show sequence-progress hints (like Vim's pending key display).
When a sync callback is provided it is added to the same pending-sync
set used by thereGlobalQueueWaiting and fires after each
processKey invocation.
Optionalsync: () => void
Optional callback invoked after each processKey so the host can re-render.
true if the current layer has a pending sequence,
false otherwise.
Register named shortcut actions that can be referenced by key bindings via string identifier instead of inline callbacks.
Enable wildcard-priority mode. In this mode, "*" (wildcard) bindings
take absolute priority over exact-key matches.
By default exact-key bindings ("return", "ctrl+s") are checked before
wildcard bindings; with this mode the order is reversed so "*" bindings
fire first. Essential for screens that must intercept every key press
(e.g. a text input capturing all printable characters).
Uses reference counting: multiple callers can enable independently. Mode disables when all callers have called the returned disable function.
A disable function. When the reference count reaches 0, wildcard priority is turned off.
Query the currently active focus target for a group.
Returns a discriminated union rather than a bare id so callers can
distinguish the "no owner / no layer / no focus / result" cases without
guessing. Check .result?.id for the active focus id, or one of
.noOwner / .noLayer / .noFound for the empty cases.
OptionalgroupOrOptions: string | FocusSetOptions
Optional focus group name (or FocusSetOptions).
Cycle to the next focus target within a group (Tab semantics).
Wraps around. When group is omitted, cycles the default group's
defaultFocusOrder; otherwise cycles the named
group's registration order. Only switches the active target — does not
activate a group that has no current focus.
OptionalgroupOrOptions: string | FocusSetOptionsCycle to the previous focus target within a group (Shift+Tab semantics).
Wraps around. See focusNext for the group parameter behavior.
OptionalgroupOrOptions: string | FocusSetOptionsActivate a named focus target on the current owner's layer.
When group is omitted, the target is looked up in the layer's default
focus group (defaultTargetsSymbol). When group is provided, the
target is looked up in the named group — each group tracks its own active
focus independently, so multiple groups can hold focus simultaneously.
The focus target id to activate.
OptionalgroupOrOptions: string | FocusSetOptions
Optional focus group name (or FocusSetOptions).
Remove a focus target from the current owner's layer.
If the removed target was the active one for its group, the first remaining target (in registration order) is auto-activated. When no targets remain in the group, that group's focus slot is cleared.
Silently no-ops when the target or group is absent on the current
layer — during unmount, sync() has already advanced the path to the
new screen, so the focusId lives on the unmounting screen's layer
(which cleanLayers() removes shortly after).
The focus target id to remove.
OptionalgroupOrOptions: string | FocusSetOptions
Optional focus group name (or FocusSetOptions).
Return a copy of the current composition context.
The active mode, or null in no-mode state.
A shallow copy of all registered global key entries, with
string operate values already resolved to functions.
The current GlobalPendingSequence if one is active — between
the first key press and completion or timeout — or null.
A shallow copy of all registered global sequence entries.
The currently hovered mouse region, or null.
Return the most recent composition event. See CompositionEngine#getLastEvent.
Return the most recent mapping-key event. See CompositionEngine#getLastMappingEvent.
A read-only snapshot of the current processor pipeline.
Register global key bindings. Global keys fire independently of the
screen stack, subject to category whitelist and affectLayer placement.
Evaluated at pipeline stages 3 and 7 — above the layer stage when
affectLayer: true, below it otherwise. Entries are matched in
registration order — the first match wins. Use this for application-wide
shortcuts (quit, toggle dev tools, switch language) that should work on
every screen. When cover is false, screens and layer elements cannot
override the key via boundKeyboard.
When operate is a string, it is resolved to a registered shortcut action.
Press-count tracking (times/pressCount) is initialized for entries with times.
Optionaloptions: { mode?: "replace" | "add" }
Optionalmode?: "replace" | "add"— 'replace' (default) replaces all global keys;
'add' appends without removing existing entries.
engine.defineShortcutAction([{
actionId: 'quit',
action: () => process.exit(0),
keys: ['ctrl+q'],
}]);
// Replace all global keys
engine.globalKeys([
{ key: 'ctrl+q', operate: 'quit' },
{ key: 'f1', operate: () => toggleHelp(), category: '*' },
{ key: 'escape', operate: handleEscape, when: () => isModalOpen, mode: 'normal' },
]);
// Add without removing existing entries
engine.globalKeys([
{ key: 'ctrl+shift+p', operate: openCommandPalette },
], { mode: 'add' });
Register global sequence key bindings. Global sequences fire independently of the screen stack with higher priority than global keys.
Evaluated at pipeline stages 2 and 6 — just above the global-key stages.
When the first key of any registered sequence matches, the engine creates
a pending global sequence and waits for subsequent keys within the
sequence timeout (default 500 ms). On a full match the handler fires and
the pending state clears; a mismatched key cancels the sequence (default)
or is silently consumed (exclusive: true). Use these for
application-wide key chords (like Vim-style g g to scroll to top).
When operate is a string, it resolves to a registered sequence action.
In 'replace' mode (default), any active pending global sequence is
cancelled before replacement.
Optionaloptions: { mode?: "replace" | "add" }engine.globalSequence([
{ keys: ['g', 'g'], operate: () => scrollToTop(), timeout: 600 },
{ keys: ['ctrl+w', 'q'], operate: 'quit-all', exclusive: true },
{ keys: ['ctrl+b', 'd'], operate: toggleDebug, mode: 'normal' },
]);
// Add without replacing
engine.globalSequence([
{ keys: ['ctrl+k', 'ctrl+k'], operate: openQuickMenu },
], { mode: 'add' });
true if the shortcut action is registered.
Whether a composition chain is currently pending.
true if the sequence action is registered.
Remove a group's active focus entry from the current owner's layer.
Kicks the entire group out of the active focus slots — the specific
focusId doesn't matter. After removal the group has no active focus
until activateFocusGroup, focusSet, or an auto-select re-establishes
one.
Returns false when the owner has no layer, the group is not registered,
or the group is not currently active. Does not unregister the group's
focus targets — bindings remain intact.
OptionalgroupOrOptions: string | FocusSetOptions
Optional focus group name (or FocusSetOptions).
true if the group was removed from active focus,
false if the group was not active or could not be found.
De-activate a built-in processor by adding it to a disabled list.
The processor is skipped on the next processKey call —
its process() method returns false immediately without running
any logic. Later pipeline stages receive key events as if the
kicked stage did not exist.
This does NOT remove the processor from the pipeline — it only disables its runtime behavior. The processor still appears in getProcessors. A kicked processor can be re-enabled at any time via activeProcessor.
Use this for temporarily suppressing a pipeline stage (e.g. disable the modal barrier, mute global keys) without permanently altering the pipeline structure.
The built-in processor ID to de-activate.
true if the processor was kicked, false if it was
already in the disabled list.
Cycle to the next mode in registration order. Wraps around.
Mark keys as transparent on the current layer. When a transparent key reaches the layer (or the named focus target), the layer's own bindings are skipped and the key continues to layers below.
Penetration means pass-through, not blocking — the key is only
released, never consumed. Penetration rules are checked first during key
matching (layer broadcast and screen-stack stages), so a key that is both
stopped and penetrated on the same layer passes through. A wildcard "*"
entry marks all keys transparent, and a when condition (callback or
registered condition id) gates the rule — when it evaluates to false
the penetration rule is ignored.
Optionaloptions: PenetrationOptionsA function that removes the transparency markers.
Remove the most recent matching owner from the stack.
Uses lastIndexOf so nested owners of the same layer unwind correctly.
Cycle to the previous mode in registration order. Wraps around.
Process a keyboard event through the full processor pipeline.
Builds a snapshot context from the engine's current state, then runs
each processor in order. The first processor that returns true
(event consumed) stops the chain. The pipeline order is:
modal → composition (affectOverlay: true) → global sequence
(affectLayer: true) → global keys (affectLayer: true) → layer
broadcast → composition (affectOverlay: false) → global sequence
(affectLayer: false) → global keys (affectLayer: false) → screen stack.
processKey itself only orchestrates the chain — side effects are
produced by the individual processors, which may mutate layers
(e.g. unbind once bindings), pending sequence state, focus targets,
composition context, or press-count counters. After the chain finishes,
pending sync callbacks registered via thereGlobalQueueWaiting
and currentScreenHasSequenceWaiting are notified so the host
framework can re-render.
Raw character string from the host framework's keyboard event.
Full key descriptor from the host framework (shape defined by normalizeKeyNames).
true if any processor consumed the event, false if it fell through.
Process a mouse event through the mouse region hit-testing.
move events drive hover transitions (onEnter/onLeave); click
events fire onClick; wheel events fire onWheel. press/drag/
release events drive the drag lifecycle: a press inside a region arms
a drag capture that the first drag event promotes (onDragStart/
onDragMove), and release fires onDragEnd — only when a real drag
happened; plain clicks stay silent.
A mouse event from the host framework's mouse adapter.
true if the event hit a registered region, false otherwise.
Push a new owner onto the owner stack so that keyboard bindings in a layer or modal element are attributed to that layer rather than the current top layer.
The "current owner" is the top of the stack — every call to boundKeyboard, boundSequence, penetration, stop, etc. registers on the layer belonging to the current owner. Layer and modal-layer rendering code pushes the layer/modal-layer id while rendering its children and pops it afterwards.
Read a layer without creating it. Returns undefined when no layer
exists for the given owner.
Unlike the other binding functions (which lazily create a layer when the
owner is missing), readLayer is strictly read-only.
Read a layer without creating it. Returns undefined when no layer
exists for the given owner.
Unlike the other binding functions (which lazily create a layer when the
owner is missing), readLayer is strictly read-only.
Read a layer without creating it. Returns undefined when no layer
exists for the given owner.
Unlike the other binding functions (which lazily create a layer when the
owner is missing), readLayer is strictly read-only.
Register a mouse region for hit-testing.
The region's layerId must match a synced layer id so hit priority
follows the same modal > layer > root order as keyboard events: while
any modal is open, only the topmost modal layer is hit-tested, and a
miss on it is dead (no fall-through). regionId is a caller-chosen
unique identifier for the region within that layer — it is independent
of keyboard element ids. rect must be in 1-based terminal
coordinates.
Within a layer, later registrations win; a priority in the region entry
overrides registration order (used for child controls like buttons).
An unregister function.
Register a composition key entry. See CompositionEngine#registryCompositionKey.
Remove all composition entries registered under key.
See CompositionEngine#removeCompositionKey.
true if the condition existed and was removed.
Remove all mapping key entries whose head key matches firstKey.
See CompositionEngine#removeMapping.
true if any entries were removed, false if the head key
was not registered.
Remove a mapping key entry by its exact key sequence. See CompositionEngine#removeMappingKey.
true if found and removed, false otherwise.
true if the mode existed and was removed.
Remove a processor from this instance's pipeline by its id.
true if found and removed, false if not found.
Restore the processor pipeline to the default 9-stage chain.
Update a condition's value. Bindings referencing this condition via
when: "id" use the new value on the next key event.
true if updated, false if the condition is not registered.
Switch to a specific mode. Pass null to exit all modes.
true if the switch succeeded, false if the mode is not
registered.
Set or replace the runtime value schema for composition chain validation. See CompositionEngine#setValueSchema.
Prevent keys from propagating beyond the current layer. A "stop barrier"
means: once a key reaches this layer, even if no binding handles it, it
does not fall through to layers below. Stop rules are checked after
bindings and penetrations, and a when condition (callback or registered
condition id) gates the rule — when it evaluates to false the key
propagates normally. A wildcard "*" entry stops all keys.
stopAction: true treats keys as shortcut action IDs: the stop rule is
stored against the action id and resolved to the action's current bound
keys at match time, so re-binding the action moves the barrier
automatically.
Optionaloptions: StopOptionsA function that removes the stop barrier.
Subscribe to composition state changes. See CompositionEngine#subscribe.
An unsubscribe function.
Subscribe to focus changes. Returns an unsubscribe function. Use this in UI frameworks to track when the active focus target moves (e.g. Tab navigation, programmatic focusSet).
Subscribe to mapping-key state changes. See CompositionEngine#subscribeMapping. Independent from subscribeComposition — mapping events do not fire composition subscribers and vice versa.
An unsubscribe function.
Push page-path, layer, and modal-layer state into the engine.
The engine does not observe the host framework's component tree — it
relies on sync being called on every render to build an accurate
snapshot. Call this synchronously on every render (before any keyboard
events) so that processKey reads a fresh snapshot. Cleanup
methods (cleanLayers, etc.) should be called in a post-render
effect so they compare the pre- and post-sync state.
The write is a direct field assignment — no merging, no diffing, no
incremental update. layers and modalLayers are expected sorted by
zIndex ascending.
Current screen system state from the host framework.
// Call synchronously on every render — before any processKey() calls
engine.sync({
pagePath: getCurrentPath(),
layers: getLayers(),
modalLayers: getModalLayers(),
});
// Post-render — remove keyboard data for detached pages/layers
engine.cleanLayers();
engine.cleanOverlayLayers();
engine.cleanModalLayers();
Check whether a global multi-key sequence is currently pending (i.e. the first key was pressed and the engine is waiting for subsequent keys or a timeout).
This is a "pull" API — it reads the pending state on demand, equivalent
to getGlobalPendingSequence() !== null. Pass a sync callback to
make it "push": the engine invokes the callback after every
processKey invocation, letting the host framework re-render when
the pending state changes.
Optionalsync: () => void
Optional callback invoked after each processKey
so the host can re-render (e.g. a useState updater).
true if a global sequence is pending, false otherwise.
// Polling — check on each render
if (engine.thereGlobalQueueWaiting()) {
// Show "g _" hint — first key of "g g" was pressed
}
// Reactive — force a re-render when the pending state changes
function useGlobalPendingState() {
const [, forceUpdate] = useState(0);
return engine.thereGlobalQueueWaiting(() => forceUpdate(n => n + 1));
}
Undo one or more completed composition sequences. See CompositionEngine#undo.
Optionalsteps: number
Number of past sequences to undo. Defaults to 1.
Optionaloptions: { byKey?: boolean; isolated?: boolean }
OptionalbyKey?: booleanOptionalisolated?: booleanWhen true, each sequence's ctx is isolated.
The final context after undo, or null if nothing was undone.
Remove a mouse region by layerId + regionId (idempotent). Needed for unmount cleanup when a region may have been re-registered multiple times via registerMouseRegion.
Update a composition entry identified by key + flag.
See CompositionEngine#updateCompositionKey.
Subscribe to unhandled key presses inside a modal. The callback receives
{ miss: false } when the key was handled, or { miss: true, key, input, eventNames }
when nothing consumed it.
Optionaloptions: ModalMissOptionsAn unsubscribe function.
Framework-agnostic keyboard state machine.
Owns all mutable keyboard state — bindings, layers, focus targets, global keys, modes, conditions, and the processor pipeline — without depending on any specific UI framework. A host framework (React, Vue, Blessed, etc.) creates an instance, calls sync on each render to push page-path, layer, and modal-layer state, and calls processKey for every keyboard event.
The generic
TComponentrepresents the host framework's component type. It defaults tounknownso the engine never constrains the host — all framework-specific detail lives in thenormalizeKeyNamesadapter and the custom processors.Example: React (via KeyboardProvider)
Example: Standalone (Node.js, no framework)