Raxol. UI. Harness. Keymap
(Raxol v2.6.1)
View Source
Harness keybind layer: canonical input event + mode context → typed
command, or :passthrough. T12 in harness-ui-roadmap.md.
The seam this closes
Raxol.UI.Harness.InputEvent (T27) already reduces every driver key-event
shape to one canonical t(). This module is the next layer: it decides
what a normalized keypress means -- interrupt the running turn, queue
a steer, toggle a block's fold, move focus between blocks -- without
knowing anything about drivers, terminals, or ANSI. resolve/2 is a pure
function: (InputEvent.t(), context()) -> command() | :passthrough. It
never touches process state, never renders, never calls into a live
component.
Why a data table, not a cond/case ladder
binds/0 returns the keymap as a plain list of maps -- match spec +
guard name + command type, no closures. resolve/2 walks it. Two things
fall out of that for free:
- tui-steal rule (chords replace keys later without restructuring
the dispatch logic -- a bind's
matchgrows amods:requirement, the walking loop does not change). This is not aspirational: akey:-kind bind with no declaredmods:matches only a BARE keypress (no ctrl/alt/meta held) -- Ctrl+Tab, Alt+Tab, and Meta+Tab all fall through to:passthroughrather than firing:steer, because they are shortcuts wired to something else, not "Tab". An entry that DOES declaremods:(a fullInputEvent.mods()map) requires an EXACT match against the normalized event'smodsinstead of the bare-keypress default -- that is the chord's match spec growing a field, per the promise above, withresolve/2's walking loop untouched. Shift alone is not restricted by the bare-keypress default (mirrorsInputEvent.text?/1: shift-only is not a shortcut), so Shift+Tab still resolves through the plain:tabbind.char:-kind binds need no separate mods check --InputEvent.printable_char/1already returnsnilwhenever ctrl/alt/meta is held, so a modifier-qualified letter never reaches the char-kind match branch in the first place. - Invocation parity with the command palette: the palette
enumerates
palette_binds/0(the labeled subset ofbinds/0) and invokescommand_for/2on any entry directly (a command palette is just another way to select a bind, not a second source of truth for what commands exist).command_types/0is the exact union this module can ever emit -- there are no commands hiding outside the table.
The composer-focus guard
Two classes of bind:
- Always live -- fire regardless of
context.composing?. ESC (:interrupt), the steer-submit key (:tab,:steer), and the Ctrl+E chord (:edit_draft-- hand the composer draft to$EDITOR) are in this class, because all three are non-printable control keys/chords that can never be part of typed text, and ESC in particular must never be swallowed by whatever currently has focus (AD-1: interrupt is a supervised kill now, not a cooperative flag queued behind typing). Ctrl+E is the firstchar:-kind chord in the table -- achar:bind that declaresmods:requires an EXACT modifier match against the normalized event (mirroring thekey:-kind rule below) instead of the printable-char path, which is nil under ctrl by design. - Guarded by
not composing?-- the block-navigation binds (fold-toggle,jump_next,jump_prev,expand_diff). These use plain printable letters (z/j/k/e), and a prior flat-keymap prototype's named bug (seeharness-ui-STATE.md, "the demo's flat keymap steals j/k/s/z from typing") was firing them unconditionally, stealing those letters out of the composer's typed text. Gating them oncomposing?is the fix: they only resolve to a command when focus is NOT the composer (browsing the transcript), and fall through to:passthrough(ordinary character insertion) while composing. An OPEN OVERLAY suppresses them the same way (the guard consultsoverlay_open?too): with a picker open,z/j/k/eare filter text the overlay must receive, never commands fired at the transcript hidden behind it -- and the picker's natural open path is exactly transcript-browse mode (composing?: false), where a composing?-only guard would fire them (see the "overlay-open ESC capture" section).e(expand the focused diff block full-screen) rides the exact same guard for the exact same reason: it is plain typed text while composing, andRaxol.Harness.Surfacereports its own footer-region expansion through the SAMEoverlay_open?context flag an open overlay picker already uses (see that module's "Full- screen diff expansion" section) -- soeis suppressed while an expansion is already open too, not just while an overlay is. It cannot collide with the Ctrl+E editor-handoff chord below:InputEvent.printable_char/1isnilwhenever ctrl is held, so a Ctrl+E keypress never reaches this bind'schar:-only match clause at all -- the two live on entirely disjoint matching paths, not a priority order. The same:not_composingguard governs the picker-opening binds below (g/s//-- jump, session, and search) for the identical reason: each is a plain printable letter that must resolve to typed text while composing, and to filter text -- never a second picker opening underneath the first -- while an overlay is already open.
The overlay-open ESC capture (order is load-bearing)
An open Raxol.UI.Harness.OverlayPicker (hosted by
Raxol.Harness.Surface.open_overlay/3) needs ESC to close ITSELF, not
fire the global interrupt -- an overlay is transient UI-local state, and
a stray ESC-while-picking must never look like a supervised kill of the
running turn. binds/0's FIRST entry (%{key: :escape, command_type: :overlay_dismiss, guard: :overlay}) captures exactly that, ahead of the
:always-guarded %{key: :escape, command_type: :interrupt} entry
later in the table -- resolve/2 walks @binds in order and takes the
first match, so this is the one place in this module where table ORDER
is part of the contract, not incidental (and it is enforced
structurally: a compile-time check below @binds raises if the two
escape entries are ever reordered or a third appears). The guarded
transcript binds honor the overlay too -- :not_composing's guard
consults overlay_open? alongside composing?, so z/j/k with a
picker open are filter text even from transcript-browse mode
(composing?: false), never fold/jump commands fired at state hidden
behind the overlay. Enter/printable chars/arrows
are deliberately NOT added to the table for the overlay: they stay
:passthrough even with context.overlay_open?: true (see
matches?/3's test coverage) -- the assembly layer (Surface) is the
one that already has the picker's state and routes those to
Raxol.UI.Harness.OverlayPicker.handle_key/2 itself.
The guard: :overlay fail-safe direction is the OPPOSITE of
:not_composing's, and deliberately so: guard_passes?/2 reads
Map.get(context, :overlay_open?, false) -- absent or false means
"no overlay is open," so ESC falls through to the :always interrupt
bind exactly as it always has. A caller that never sets overlay_open?
(every existing caller, before this change) is completely unaffected --
nothing load-bearing changes for them. Only an explicit
overlay_open?: true opts INTO the dismiss reading. This is the safe
direction here for the reason :not_composing's fail-safe is the
OPPOSITE way: the dangerous failure mode for THIS guard is silently
swallowing the global ESC-interrupt when no overlay is actually open (a
caller bug would make ESC do nothing at all, a much worse silent
failure than dead navigation keys), so the guard must default to
"closed" and require an explicit opt-in to ever capture ESC.
Fail-safe default: missing composing? means composing
A caller that omits composing? from context() entirely (forgets it,
or is a code path -- palette invocation, a future non-composer
interaction -- that never renders a composer at all) gets the
fail-SAFE reading: treated as composing, so the guarded binds
(fold-toggle, jump_next, jump_prev) resolve to :passthrough,
never firing. This is deliberately asymmetric with what would be
simplest to implement (Map.get(context, :composing?, false), "missing
means not composing"), because the two failure directions are not
equally bad:
- Fail-safe (this module's choice): a caller that never sets
composing?gets deadj/k/znavigation. That is immediately discoverable -- the keys visibly do nothing -- and ESC/Tab are unaffected (:alwaysbinds never consultcomposing?), so nothing load-bearing is silently broken. - Fail-open (the rejected alternative): a caller that never sets
composing?gets the guarded binds firing unconditionally -- exactly the named prototype bug this module exists to fix (see below), except now triggered by an absent flag instead of a flat keymap with no guard at all. Typedj/k/zwould silently turn into fold-toggle/jump commands instead of inserting characters, with no crash and no visible error -- the worst kind of bug, because it looks like normal typing until content goes missing.
Only an explicit composing?: false opts a caller INTO guarded-bind
resolution. context() also normalizes a bare nil (e.g. "no block
focused" callers that pass nil instead of %{}) to %{} before any
guard consults it, so the crash surface is not bind-dependent -- see
resolve/2's doc.
context() is deliberately small: composing? (is the composer
focused/receiving text), streaming? (is a turn currently running), and
focused_block_id (which block, if any, a fold/jump command should
target). streaming? does not gate ESC -- interrupt is unconditional and
immediate (roadmap: "ESC during streaming emits interrupt, not
buffered"); sending it when nothing is running is a harmless no-op
downstream, and NEVER emitting it because of a mode check is the failure
mode AD-1 exists to rule out.
Steer-submit: why Tab, not a modified Enter
Plain Enter (single-line, non-empty buffer) already submits as :prompt
entirely inside Raxol.UI.Components.Harness.Composer (T11) -- that
path never reaches this module. Steer needs a different key, and it
must be one that can fire while the user is mid-composing (queuing a
steer message is the whole point), so it cannot be a plain printable
character (those are text, full stop -- see the guard above). Tab is
the documented precedent for exactly this shape of decision
(harness-spec-protocol.md / harness-spec-frontend.md: "the corpus's
named steer-vs-interrupt primitive (Tab=queue / Enter=now)"); it also has
a first-class canonical key atom (:tab) verified across the real
termbox and ANSI wires by T27, and Composer does not otherwise bind it.
Command shape: reusing U3's channel without a compile-time dependency
packages/raxol_agent owns the live command struct
(Raxol.Agent.Command, %Command{type: atom(), payload: map()},
U3/#543) and the harness protocol's validation seam (decode/1). Main
raxol does not (and per the package dependency graph, must not) depend
on raxol_agent -- this module ships in the harness-UI lane, which is
fixture-driven and requires no agent lane (T13a's own acceptance: "No
agent lane required"). Per the documented cross-package convention
(CLAUDE.md: "Struct patterns across package boundaries use map
patterns... instead of struct patterns"), command() here is a plain
map with the same field names U3 shipped -- %{type: atom(), payload: map()} -- not the struct. This is not a parallel command type: it is the
identical wire shape, one struct(Raxol.Agent.Command, cmd) away from
the real thing at the one boundary (T13b's live wiring) that actually
depends on raxol_agent.
:interrupt mirrors U3's existing type verbatim (empty payload, same as
U3's own :interrupt validation). :steer mirrors the protocol spec's
documented (not yet decoded by U3) steer type
(harness-spec-protocol.md §4: %{text}) -- T12 emits an empty payload
since it has no access to composer text (a pure keymap, per the roadmap,
takes the event + a small mode context, not component state); the
assembly layer that already has the composer's buffer fills payload.text
in before dispatch. :fold_toggle / :jump_next / :jump_prev are new
vocabulary this unit adds (the task's own named example of "a needed
command kind [that] doesn't exist yet") -- transcript-navigation actions
that never need to leave the UI lane, so they ride the same channel for
the command palette's invocation parity without ever being routed to
raxol_agent. :open_palette / :open_jump_picker /
:open_session_picker / :open_search_picker are the same kind of
UI-local vocabulary, added for the pickers below -- :open_search_picker
(/) is gated :not_composing exactly like :open_jump_picker/
:open_session_picker (g/s): transcript-browse only, filter text
while an overlay is open, typed text while composing.
Palette derivation (the label field)
A bind opts into the command palette by declaring a label: -- a short,
human-readable string. palette_binds/0 is exactly the labeled subset
of binds/0, in table order; nothing else decides what appears in the
palette. The overlay-only :overlay_dismiss bind is deliberately left
unlabeled: the palette IS itself an overlay, so listing "dismiss the
overlay" as something the palette can invoke on itself would be
nonsensical. Every other bind that names a discrete, invokable action
carries a label; adding a new labeled bind to the table is the only
change needed to make it appear in the palette.
Summary
Functions
The keymap as data -- one entry per v1 bind. Exposed so T15's command palette can enumerate every invokable command without a second table.
Emits the exact command resolve/2 would dispatch for an event matching
bind, without needing a live InputEvent.t() -- this is what makes the
command palette's invocation parity real: it can invoke any
palette_binds/0 entry directly instead of needing to fabricate a
matching keypress. A bare nil context normalizes to %{}, same as
resolve/2. :fold_toggle threads context.focused_block_id into the
payload exactly like a live keypress would (see
picker_binds_test.exs's "command_for/2 threads context into
:fold_toggle's payload" case).
The exact set of command types this module can ever emit -- the union
binds/0 declares, nothing hidden outside the table. Used by the
invocation-parity test and available to T15 for the same reason.
The labeled subset of binds/0 -- a bind opts into palette listing by
declaring a label:; derived from the table, never a parallel list.
See picker_binds_test.exs's "palette derivation" describe.
Resolve a normalized InputEvent.t() (see Raxol.UI.Harness.InputEvent
-- callers normalize first; this module never sees a raw driver event) +
a mode context into a typed command, or :passthrough when no bind
applies (composer-focus guard blocked it, or the key simply isn't bound).
Types
@type bind() :: %{ :command_type => command_type(), optional(:key) => atom(), optional(:char) => String.t(), optional(:guard) => guard(), optional(:mods) => Raxol.UI.Harness.InputEvent.mods(), optional(:label) => String.t(), optional(:payload) => map() }
@type command() :: %{type: command_type(), payload: map()}
@type command_type() ::
:interrupt
| :steer
| :edit_draft
| :fold_toggle
| :jump_next
| :jump_prev
| :overlay_dismiss
| :open_palette
| :open_jump_picker
| :open_session_picker
| :open_panel
| :expand_diff
| :open_search_picker
@type guard() :: :always | :not_composing | :overlay
Functions
@spec binds() :: [bind()]
The keymap as data -- one entry per v1 bind. Exposed so T15's command palette can enumerate every invokable command without a second table.
Emits the exact command resolve/2 would dispatch for an event matching
bind, without needing a live InputEvent.t() -- this is what makes the
command palette's invocation parity real: it can invoke any
palette_binds/0 entry directly instead of needing to fabricate a
matching keypress. A bare nil context normalizes to %{}, same as
resolve/2. :fold_toggle threads context.focused_block_id into the
payload exactly like a live keypress would (see
picker_binds_test.exs's "command_for/2 threads context into
:fold_toggle's payload" case).
@spec command_types() :: [command_type()]
The exact set of command types this module can ever emit -- the union
binds/0 declares, nothing hidden outside the table. Used by the
invocation-parity test and available to T15 for the same reason.
@spec palette_binds() :: [bind()]
The labeled subset of binds/0 -- a bind opts into palette listing by
declaring a label:; derived from the table, never a parallel list.
See picker_binds_test.exs's "palette derivation" describe.
@spec resolve(Raxol.UI.Harness.InputEvent.t(), context() | nil) :: command() | :passthrough
Resolve a normalized InputEvent.t() (see Raxol.UI.Harness.InputEvent
-- callers normalize first; this module never sees a raw driver event) +
a mode context into a typed command, or :passthrough when no bind
applies (composer-focus guard blocked it, or the key simply isn't bound).
A bare nil context (e.g. "no block focused" callers that pass nil
instead of %{}) normalizes to %{} before any guard consults it --
every field then falls back to its documented default (see the
moduledoc's fail-safe-default section for composing?) instead of the
guard raising on a non-map.
Pure: no process state, no side effects, never raises on a well-formed
InputEvent.t().