Keymap Architecture (developer reference)
Authoritative design for lattice's key-input dispatch. The plan section at the end lists the slices that take us from the current state (input.rs hand-rolled match table; keymap.rs documentation-only) to the architecture design.md §5.2.3 has spec'd since day one.
1. Vision
"Keymaps are configuration that bind chord sequences to command invocations -- the default vim keymap is itself a config file, not hardcoded behavior. Any user or plugin can invoke any command, compose commands, or build entirely new editing flows." -- design.md §3 / §5.2.3
The architecture commitments this implies:
- Bindings are data, not code. Built-in vim defaults, user config, major-mode bindings, minor-mode bindings, and plugin bindings all live in the same registry. There is one
bindAPI; three callers (built-ins, userinit.rs, plugins). - The grammar IS the public command API (paramount goal #3). A plugin that registers a new motion / operator / text object can also bind a chord to it. No host changes required.
- Plugin extensibility is first-class (paramount goal #2). Plugins ship in any WebAssembly Component Model language; capability-gated access to
keymap-writelets them register bindings; binding registration cannot violate vim semantics (the chord still resolves to a typedCommandInvocation). - Performance lives on the keystroke path. Lookup is sub-microsecond, allocation-free, wait-free against registration writes. The mechanism scales with the binding count, not the buffer size.
2. Five-layer model (design.md §5.2.3)
Keymap resolution walks layers in priority order:
| Priority | Layer | Source |
|---|---|---|
| 1 | Built-in vim default keymap | host registers at startup; lives in code as keymap_entry! macros |
| 2 | Major-mode keymap | host registers when a major mode activates (rust, markdown, ...) |
| 3 | Active minor-mode keymaps | pushed/popped as minor modes activate (active-snippet, completion-popup, picker, chord-capture, ...) |
| 4 | User config overrides | init.rs calls keymap.bind(...) at boot |
| 5 | Per-buffer ad-hoc bindings | :nmap <buffer> or plugin per-buffer override |
Higher priority overrides, not shadows: a user-config binding for dd replaces the built-in dd; if the user later unbinds it, the built-in resurfaces.
Within a layer, last-bind-wins. Both bindings retain source provenance so :describe-key dd can show "dd → delete-line (user, init.rs:42; previously vim default, keymap.rs:213)".
The five layers live in two physical structures:
merged(always-on) — Builtin + MajorMode + User + Buffer layers merged perBindingModeat write time. Fires unconditionally on every keystroke. Rebuilt bybuild_always_on_mergedwhenever any of those four layers change.minor_mode_tries(per-buffer overlay) — oneHashMap<BindingMode, KeymapTrie>perMinorMode(id), stored in a secondArcSwap. At lookup time,lookup_with_contextstarts from the always-on trie and folds in only the MinorMode tries whoseModeIdis inactive_modesfor the current buffer, in activation order (last-activated minor wins).
Layer mutations are infrequent (mode transitions, config reload, minor-mode push/pop). Keystroke lookups are fast: the no-minor path is one ArcSwap::load + trie walk (~17 ns single-chord); the minor-fold path overlays 0–3 tries over the base before walking (~44 ns typical). Both structures use ArcSwap for wait-free reads; the brief mutex covers only the source-of-truth layer stack used for rebuilds.
3. Data model
3.1 KeyChord
Canonical, owned representation of one chord token. Stack-only (no allocation).
pub struct KeyChord {
pub key: KeyKind, // Char(c) | Special(SpecialKey) | Function(u8) | ...
pub modifiers: KeyMods, // bitfield: ctrl / shift / alt / super
}
Notation (lossless round-trip):
- Plain char:
j,0,$ - Modifier-prefixed:
<C-d>,<S-Tab>,<C-S-Right> - Special:
<Esc>,<CR>,<Tab>,<BS>,<Up>,<Down>, ... - Function:
<F1>...<F24>
Multi-key chords are a [KeyChord] slice, not a single KeyChord. The trie indexes by chord-by-chord descent, not by concatenated string.
3.2 KeymapTrie
Hash-trie indexed by chord prefix. Each node carries:
children: HashMap<KeyChord, Node>(orVec<(KeyChord, Node)>if measurement says small-N is faster).binding: Option<Arc<BoundCommand>>(Some at terminal nodes; partial nodes carry None).
Lookup:
- Walk children for the next chord; descend.
- Terminal node → return
Arc<BoundCommand>. - Internal node with no terminal at this depth → return
LookupResult::Partial(waiting for next chord). - No matching child →
LookupResult::Unbound.
Lookup cost is O(prefix_length) hash lookups, not O(binding_count). Most chords are length 1–2.
3.3 BoundCommand
pub struct BoundCommand {
pub command: CommandInvocation, // typed, registry-resolved
pub source: SourceLocation, // provenance for :describe-key
pub layer: KeymapLayer, // priority tag for conflict resolution
}
CommandInvocation is the same typed type the grammar dispatcher consumes (DESIGN §5.2.1). The keymap is just a chord → invocation index; once resolved, dispatch reuses the existing execute(invocation) path. No special-case "run an Action" detour for built-ins.
The legacy Action enum in crate::app collapses into CommandInvocations once migration completes. Built-in motions / operators / mode transitions are already reachable through the registry; the Action enum exists today only because input.rs needs an enum to dispatch into App methods. After M3 the keymap returns CommandInvocations; the App's apply loop already routes those.
3.4 KeymapRegistry / KeymapHandle
Lives in crates/lattice-keymap (extracted from lattice-host during the T1-T9 crate migration; see §13). The App holds a KeymapHandle (cheap Arc clone).
// crates/lattice-keymap/src/registry.rs
pub struct KeymapHandle {
registry: Arc<KeymapRegistry>,
}
struct KeymapRegistry {
/// Always-on merged trie: Builtin + MajorMode + User + Buffer.
/// Wait-free read; rebuilt on writes to those four layer kinds.
merged: Arc<ArcSwap<MergedKeymap>>,
/// Per-ModeId MinorMode tries. Wait-free read; rebuilt on
/// push_layer / pop_layer for MinorMode layers.
minor_mode_tries: Arc<ArcSwap<HashMap<ModeId, Arc<HashMap<BindingMode, KeymapTrie>>>>>,
/// Source-of-truth layer list used for rebuilds and telemetry.
inner: Arc<Mutex<LayerStack>>,
}
The two-ArcSwap split means write to Builtin/MajorMode/User/Buffer only rebuilds merged; push/pop of a MinorMode layer only rebuilds minor_mode_tries. Neither touches the other structure.
Keystroke read API:
lookup_with_context(&self, mode: BindingMode, chords: &[KeyChord], active_modes: &[ModeId]) -> LookupResult— hot path; wait-free (no mutex). Folds active MinorMode tries over the always-on merged trie in activation order.
Telemetry / introspection API (not on the hot path):
enumerate_chord_bindings(&self, mode: BindingMode, chords: &[KeyChord]) -> Vec<(KeymapLayer, Arc<BoundCommand>)>— walks the sourceinner.layersunder mutex; returns every registered binding for the chord regardless of active modes.resolve_trace(&self, mode: BindingMode, chords: &[KeyChord], active_modes: &[ModeId]) -> KeymapResolution— builds a full per-layer trace withactiveflags; used by:describe-key. See §13.2 for semantics.resolve_trace_all_modes(&self, chords: &[KeyChord], active_modes: &[ModeId]) -> Vec<KeymapResolution>— runsresolve_tracefor everyBindingMode; returns only modes that have at least one registered binding.layer_label_string(&self, layer: KeymapLayer) -> String— human-readable label from the layer's registered label string.
Write API:
bind(&self, layer: KeymapLayer, mode: BindingMode, path: &[ChordPattern], cmd: CommandInvocation, source: SourceLocation)unbind(&self, layer: KeymapLayer, mode: BindingMode, path: &[ChordPattern])push_layer(&self, kind: PushLayerKind, modes_and_bindings: ...) -> LayerIdpop_layer(&self, id: LayerId)
After every write the affected structure (merged or minor_mode_tries) rebuilds. Rebuild cost is typically sub-millisecond for the default catalog (~hundreds of bindings per layer, ~5 layers). The ArcSwap::store is one atomic.
Capability-gated variants for plugin/user access: try_bind, try_unbind, try_push_layer — funnel through a capability_allows check before delegating. See §5.5.
3.5 KeymapEntry (existing; stays)
The existing KeymapEntry in keymap.rs:134 becomes the catalog of built-in bindings. The keymap_entry! macro constructs entries; a startup pass enumerates the catalog and registers each into the KeymapRegistry. The catalog gives us:
- Source-location capture for free (
file!()+line!()). - Doc / mode / chord all in one place.
- The
:describe-key,:keymap,:aproposintrospection surfaces continue to work.
The drift test that catches descriptor / behaviour divergence becomes obsolete because the descriptor IS the behaviour.
Multi-mode entries (B-field). A KeymapEntry carries modes: &'static [BindingMode], not a single mode. The keymap_entry! macro accepts either a single mode (mode: Normal, which sugars to a one-element slice — existing call sites are unchanged) or a bracketed list (mode: [Normal, Visual]). The startup translation pass (resolve_entries_into_bindings) fans the slice out into one KeymapBinding per mode — the trie is per-mode, so a multi-mode entry is N bindings sharing chord / command / source. :describe-key shows one entry naming all its modes (zn (Normal, Visual)); :keymap lists it under each mode group. This keeps a multi-mode mapping a single declarative entity instead of forcing the author to repeat the chord per mode. The two imperative / builder peers are in §5.6.
4. Lookup path (the keystroke hot path)
crossterm::KeyEvent
↓ (input thread)
KeyChord normalisation (allocation-free; ~50 ns)
↓
input.rs::translate
├─ overlay claims first? (picker / completion / snippet / chord-capture / help)
│ yes → overlay layer's lookup
│ no → pending-state lookup
↓
KeymapHandle::lookup_with_context( (K.1.c; ArcSwap::load + trie fold + walk)
mode, ~17 ns no-minor-modes fast path
&partial_chord, ~44 ns typical (0-3 active minors)
&active_modes_for_buffer,
)
↓
LookupResult
├─ Bound(cmd) → dispatch via grammar's execute(cmd)
├─ Partial → AbsorbPartialChord, wait for next chord
└─ Unbound → fall through to literal-text Insert / no-op Normal
active_modes_for_buffer is read from App::active_modes[active_buffer_id()] — the ActiveModes { major, minors } for whichever pane is focused (Document, Multibuffer, Terminal, Help, …). Using active_buffer_id() rather than a document-specific field is important: non-Document panes have their own mode stacks and must not borrow another pane's minor-mode context (see §13.3).
Performance commitments
- Lookup p99 < 1 µs including chord normalisation and trie walk. Bench-gated.
- Zero allocation on the lookup hot path.
KeyChordis stack-only;BoundCommandis reached throughArcclone (one atomic increment); the dispatch path then invokes the grammar with the already-typed invocation. - Wait-free reads via
ArcSwap<KeymapTrie>. Concurrent registration (plugin loading, user:bindat runtime, minor-mode push/pop) does not stall the input thread. Same pattern asSupervisorSnapshot(audit slice 1) andDiagnosticsSnapshot(audit slice 2). - Layer-merge on write, not on read. Pushing a minor-mode layer rebuilds the merged trie. With ~hundreds of bindings per layer and ~5 layers active, rebuild is sub-millisecond and happens on mode transitions (rare). Read latency is unaffected by the layer count.
5. Registration paths
5.1 Built-ins
A startup pass enumerates the existing KeymapEntry catalog in keymap.rs and registers each entry into the built-in layer:
for entry in BUILTIN_KEYMAP_CATALOG.iter() {
registry.bind(
KeymapLayer::Builtin,
entry.mode,
&chord_seq_from_str(entry.chord),
invocation_for(entry.command),
entry.source,
);
}
The keymap_entry! macro stays the source-of-truth construction path; the catalog stays the documentation surface; what changes is that the catalog now drives the dispatcher.
5.2 Major modes
Major modes (rust, markdown, ...) declare a keymap as part of their MajorMode registration (DESIGN §5.9). The host registers the major-mode layer when the mode activates, drops it when the mode deactivates.
fn rust_mode_keymap() -> Vec<KeymapEntry> { ... }
5.3 Minor modes
Minor modes (active-snippet, completion-popup, picker, chord-capture, help-overlay) push a layer with their bindings; pop on deactivation. Today these are special-case branches at the top of input.rs::translate; after M3 they're plain layer pushes:
let layer_id = registry.push_layer(MinorModeLayer {
bindings: completion_popup_bindings(),
label: "completion-popup",
});
// ... popup is open ...
registry.pop_layer(layer_id);
The branch ordering at the top of translate (picker before snippet before chord-capture before universal <C-c>) becomes layer ordering in the stack.
5.4 User config (init.rs)
The user's compiled-to-WASM init.rs calls the same bind API:
keymap.bind(KeymapLayer::User, BindingMode::Normal, "<leader>w", ":w<CR>")?;
keymap.unbind(KeymapLayer::Builtin, BindingMode::Normal, "<leader>w")?;
keymap.bind(KeymapLayer::User, BindingMode::Normal, "j", "gj")?; // nnoremap j gj
Same mechanism, different layer.
5.5 Plugins
Plugins reach the keymap through the WIT interface (DESIGN §9):
interface keymap {
bind: func(layer: layer, mode: binding-mode, chord: string, cmd: command-id) -> result<binding-id, keymap-error>;
unbind: func(id: binding-id) -> result<_, keymap-error>;
push-layer: func(label: string, bindings: list<entry>) -> result<layer-id, keymap-error>;
pop-layer: func(id: layer-id) -> result<_, keymap-error>;
}
Capability gating. Plugins must declare keymap-write in their manifest to register bindings. Layer-scoped capabilities (keymap-write:minor-mode, keymap-write:plugin-only-layer) let the host restrict a plugin to its own layer; the host can deny a plugin's request to bind in KeymapLayer::Builtin or KeymapLayer::User. Mirror of the typed-options / filesystem capability gates.
A plugin that ships a new motion can also ship a binding for it:
// in plugin.wit
let motion = registry.register_motion(...);
keymap.bind(KeymapLayer::PluginA, BindingMode::Normal, "]f", motion.id)?;
This is the architectural seam paramount goal #3 has been asking for since day one.
5.6 Multi-mode bindings (one chord, several modes)
A chord that means the same thing in several modes is declared ONCE, across all three registration paths. The trie stays per-mode; the multi-mode surface is sugar that fans out into per-mode inserts:
- Catalog (declarative): the
keymap_entry!mode: [..]form (§3.5).keymap_entry! { mode: [Normal, Visual], chord: "zn", doc: "...", cmd: "operator:narrow" }is one entry, fanned out byresolve_entries_into_bindingsinto a binding per mode. - Builder:
Keymap::bind_chord_modes(&[BindingMode], chord, command)— the multi-mode peer ofbind_chord; pushes oneKeymapBindingper mode. For a mode'skeymap()contribution. - Imperative:
KeymapHandle::bind_modes(layer, &[BindingMode], path, command, source)— inserts into every named mode's trie under one lock and rebuilds the merged trie + reverse cache ONCE (not per mode). Forinit.rs/ host helpers binding directly.
There is deliberately NO :map / :nmap / :vmap ex-command family: init.rs and plugins use the typed API (or the WIT bind), so a separate string-command surface would be a redundant second way to do the same thing (heuristic #1). A single mode is just a one-element slice — the single-mode forms (bind, bind_chord, mode: Normal) stay the common case and are unchanged.
Note this is distinct from an operator acting on the Visual selection (§7.2, upgrade 3): that is intrinsic to being an operator and is generated per-operator, NOT a multi-mode binding of the same CommandInvocation (the Normal op-pending and Visual forms carry different Range / target semantics).
5.7 A minibuffer is not an Insert buffer
The : line, the /·? line and the generic prompt are buffer-backed readline surfaces, and each resolves keys in its own binding context — BindingMode::Command, Search and Prompt respectively. They do not borrow Insert's.
This is load-bearing, not tidiness. ModalState::Command used to route through dispatch_insert, which resolved against BindingMode::Insert, so a minibuffer inherited the Insert table entire — including every globally-active minor's Insert bindings. auto-pair declares ActivationPolicy::Global and binds <BS>, so on the : line its handler shadowed the builtin backspace: backspace did nothing while typing a command, in both renderers, for as long as the minibuffer has been a buffer.
It failed in the other direction too. BindingMode::Command existed; :describe-key reported c_ bindings against it (vim's convention); the WIT layer mapped a plugin's command binding onto it. None of them could ever fire, because the live path asked the Insert table. A plugin binding a command-line key was writing into a table nothing read.
Two rules follow:
- A minor that binds in Insert binds in Insert only. That is what keeps editing-buffer behaviour out of the minibuffers, and it is enforced by the tables being distinct rather than by anyone remembering.
- Each surface's table must be complete.
register_insert_bindingsregisters the builtin readline set —<BS>,<C-w>,<C-u>,<C-a>/<C-e>/<C-k>, arrows,<Home>/<End>— into all four contexts, anda_minibuffer_is_not_an_insert_buffer.rsasserts every one of them resolves on every surface. Miss one and that key dies on that surface, which is the original bug with a different victim.
Both renderers get this from one place: lattice_host::input::translate picks the context from the ModalState, so the TUI and GPUI cannot disagree. The GPUI peer has its own test for it anyway (lattice-ui-gpui), because a parity claim that nothing checks is how the last one stayed wrong for a month.
6. Conflict resolution + provenance
- Cross-layer conflict: top layer wins (priority order in §2). The lower-layer binding stays in the registry; if the higher binding is unbound, the lower one re-surfaces.
- Within-layer conflict: last-bind-wins, but
sourceprovenance keeps both visible to introspection.:describe-key ddshows the active binding plus a "shadowed by user init.rs:42" trail when relevant. - Capability-denied: registration errors with
KeymapError::CapabilityDenied. The plugin's request is rejected at the host boundary; lattice never silently ignores a registration.
7. Counts, operator-pending, and other vim layered concerns
7.1 Counts
3w is two layered concerns: an integer count, then a chord. Counts are not bindings; they're an input-thread accumulator that runs before keymap lookup. Today's pending_count in input.rs stays exactly where it is.
After M3:
- Input thread accumulates digits while the chord prefix is empty.
- Once a non-digit chord arrives, lookup runs with the accumulated count attached to the resulting
CommandInvocation's count field. - Dispatch unchanged;
execute(invocation_with_count)works today.
7.2 Operator-pending
d followed by w is a multi-key chord in the trie, not a mode transition. The trie naturally encodes it: d is an internal node with a child w (and e, b, 0, $, iw, aw, ...). Lookup of d returns Partial; the dispatcher sets Pending::Operator(d) and waits for the next chord. On w, lookup of [d, w] returns Bound(delete-with-target=word-forward).
Upgrades over today's hand-rolled state machine:
- No special-case Pending branches for each operator.
d,y,c,>,<,gU,gu,g~all encode the same way: trie nodes with motion / text-object children. - Plugin-defined operators get plugin-defined chord sub-trees for free. A
sort-linesoperator registered by a plugin can register its bindings as[s, l](or whatever); the dispatcher walks the trie identically. - Operators act on the Visual selection BY DESIGN — both modes from one registration. An operator is not merely an op-pending prefix in Normal; in Visual the same trigger chord applies the operator to the active selection (
op.with_range(Range::Selection)) and exits Visual. That is intrinsic to being an operator, not a per-operator Visual binding.keymap_normal::register_operator_bindingsemits BOTH surfaces from one call — the Normal op-pending family AND the Visual selection-bind — uniformly for builtin (d/c/y/>/<,gU/gu/g~) and contributed (narrow'szn) operators alike. The grammar stays chord-agnostic (OperatorIds, never keys), so this uniform generation lives at the host keymap layer where chord→command already lives. It subsumes the old hand-rolled Visualoperator_table; the only Visual operator entries NOT generated this way are the two genuine Visual-only aliasesx→delete ands→change (in Normalx/sare different commands, so they cannot be derived from the operator registration). A contributed operator reaches Visual with zero host edits.
7.2.1 post_motion_char — post-motion wildcard capture
Some operators need a character argument AFTER the motion range resolves (e.g. surround's ys{motion}{char} — the motion determines WHAT to wrap, the trailing char determines the wrapping pair). register_operator_bindings accepts a post_motion_char: bool parameter (default false). When true, every motion, text-object, and syntax-motion binding path gets an additional ChordPattern::CharLiteral at the end:
ysw → [y, s, w] (without post_motion_char)
ysw → [y, s, w, CharLiteral] (with post_motion_char)
ysiw → [y, s, i, w, CharLiteral] (text-object)
ys]f → [y, s, ], f, CharLiteral] (syntax motion)
The motion target's args are set to the non-None placeholder Args::Char('\0') so the trie's substitute_invocation_char_arg routes the captured char to the invocation's args slot (the wrapping character) rather than to the motion target's args. Multi-char capture support in action_from_bound_with_capture (handling captured.len() > 1) ensures operators with two wildcards (e.g. surround's cs{char1}{char2}) receive both captured chars as Args::List([ArgValue::Char(c1), ArgValue::Char(c2)]).
The flag is also stored in OperatorSpec::post_motion_char (native) and operator-spec.post-motion-char (WIT) so WASM plugin operators can declare the same intent. The host's build_operator_spec trampoline plumbes the WIT field through to the native spec.
7.3 Marks, registers, find-char
'a (jump to mark line), "ay (yank to register a), fX (find char X forward) are all single-character "argument" chords. They're partial trie nodes whose children are character-class wildcards. The trie supports a Wildcard child type alongside the literal-chord children.
enum TrieChild {
Literal(KeyChord), // exact match
CharLiteral, // any single char (mark name, register, find-target)
}
Lookup with f followed by X walks Literal(f) → CharLiteral, binding the char as an arg to the resulting invocation.
This subsumes the AfterMark, AfterRegister, AfterFindChar states in today's BindingMode enum. Those enum variants become trie-internal state, not separate dispatch modes.
8. Renderer / runtime integration
The keymap registry is held on the App as KeymapHandle (Arc-shareable). The input loop's translate function takes &KeymapHandle and a BindingMode. No other render or runtime path needs to know about the keymap.
The handle's snapshot pattern means the input loop is unaffected by registration writes from any thread. The same guarantee that lets multi-thread render work after audit slice 7 works for multi-thread input dispatch.
9. Migration plan (Slices 8.a -- 8.i)
Each slice ships the four artefacts CLAUDE.md heuristic #5 requires: architecture documentation (this file is the home for it; per-slice updates as design evolves), benchmark coverage (crates/lattice-ui-tui/benches/keymap.rs -- new file in 8.a), tests for new scenarios + failure modes, and graceful error handling.
Slice 8.a -- Foundation: KeyChord + chord-string normalisation
- New crate-internal type
KeyChord(stack-only). KeyEvent → KeyChord(input.rs's existing key normalisation becomes a pure function).KeyChord → String(the<C-d>notation already documented inkeymap.rs:17-22).String → [KeyChord]parser for chord-sequence strings ("gg","<C-w>j","dw").- Property tests: round-trip
KeyEvent → KeyChord → String → [KeyChord]for the full key alphabet + modifier matrix. - Bench: parse cost, normalisation cost.
Slice 8.b -- KeymapTrie
- Hash-trie data structure with
Literal+CharLiteralchild kinds (§7.3). insert/lookup/remove/merge.Arc<ArcSwap<KeymapTrie>>cell for wait-free reads (mirror ofSupervisorSnapshot).- Bench: lookup at depths 1, 2, 3; merge cost for 5 layers with ~500 bindings each.
- Tests: prefix lookup, partial vs unbound, char-literal wildcard, layer merge with shadowing.
Slice 8.c -- KeymapRegistry + layered resolution
- The five-layer model from §2.
bind/unbind/push_layer/pop_layer.lookup(chord_seq, mode)walks the merged trie, returnsLookupResult.- Built-in catalog enumeration → registry registration at startup. The existing
keymap_entry!macro stays. - Plugin / user-config API surface (skeleton -- capability gate lands in 8.h).
- Tests: layer push/pop is observable in lookup; same chord rebound at higher layer wins; unbind exposes lower layer.
Slice 8.d -- Migrate Replace mode (proof of pattern) ✅ landed
- Four binding shapes (
<Esc>,<BS>,<CR>, char wildcard) registered inkeymap_replace::register_replace_bindingsintoKeymapLayer::Builtin+BindingMode::Replace. App::newconstructs aKeymapHandle, registers the Replace catalog, and the runtime threads&app.keymapthroughTranslateContext.input::translatecallsdispatch_replace(ctx.keymap, &event)forModalState::Replace-- the legacytranslate_replacematch table is gone.- Drift test in
keymap_replace::testskeeps the dispatcher honest against a frozen reference of the legacy body across the cross-product of {key code} × {modifier set}. Pinned end-to-end throughtranslateby additional tests ininput::tests::*_in_replace*and thereplace_dispatch_reads_from_handle_not_baked_in/alt_x_in_replace_overwrites_with_xcases. - Sets the migration template subsequent slices follow: per-mode
register_<mode>_bindings+dispatch_<mode>, drift test against a private reference body, and the per-mode arm ofinput::translateswitches to the registry-driven path.
Slice 8.e -- Migrate Visual mode ✅ landed
- ~30 chord registrations covering exits (
<Esc>/v/V), motions (hjkl,0$^,wbe,WBE,{}/(),G, arrow / Home / End aliases), and operators on selection (d/x,c/s,y,>/<). - Motions and operators register through
BoundCommand::from_invocation-- the dispatcher returnsAction::Invoke(command.clone()). Only the threeExitVisualexits and the two block-onlyEnter*paths still carry alegacy_action(noCommandInvocationpeer today; slice 8.i's bridge retirement closes that gap). - Block-only
I/Aare pre-lookup overrides indispatch_visualuntil the architecture's minor-mode-on-Visual layer push lands; the drift test pins the kind branch so a future graduation topush_layeris mechanical. input::translate'sModalState::Visual(kind)arm now callsdispatch_visual(ctx.keymap, &event, kind); the legacytranslate_visualbody is gone.- Drift test in
keymap_visual::testswalks the cross-product of {key code} × {modifier set} × {VisualKind}, asserting parity with a frozen reference body of the legacytranslate_visual. Existinginput::tests::*_in_visual_*cover the wiring end-to-end throughtranslate. - The test fixture in
input::testsnow shares one process- wide(Builtins, KeymapHandle)pair so trie-boundCommandInvocationids match theBuiltinseach test references.
Slice 8.f -- Migrate Insert mode ✅ landed
- Base Insert bindings registered by
keymap_insert::register_insert_bindings:<Esc>,<BS>,<CR>,<Tab>,<C-Space>, plus the two-key paths[<C-x>, <C-o>]and[<C-x>, <C-s>].<C-x>itself is a partial trie node;dispatch_inserttranslates thePartiallookup intoSetPending(AfterCtrlX)and resolves the follow-up keystroke via[<C-x>, normalised(event)]. The next slice (8.g) generalises this to every prefix in Normal mode (g_,z_,<C-w>, mark/register/find-char wildcards). - Literal-text fall-through stays in
dispatch_insertrather than being registered as a char wildcard, per the original bullet -- the dispatcher returnsAction::Insert(c.to_string())for any unbound non-CONTROLChar(c)lookup. When the popup overlay layer is pushed, its char wildcard wins, so typing routes throughCompletionAcceptThenInsert(c)instead. - Completion-popup overlay ->
KeymapLayer::MinorModelayer pushed byApp::sync_keymap_overlayswheneverinsert_completion.is_some(); popped on close. - Active-snippet overlay ->
KeymapLayer::MinorModelayer pushed wheneveractive_snippet.is_some(). Push order (snippet first, popup second) means popup'sLayerIdis always higher; popup wins on overlapping chords (preserving the legacy "popup precedes snippet" gating). The sync pops everything and re-pushes in canonical order whenever overlay state changes. - Modifier-stripping rules in
dispatch_insertare mode-specific (see the table inkeymap_insert.rs's module docstring): ALT and SUPER are stripped; CTRL and SHIFT are preserved so<C-y>stays distinct fromyand<S-Tab>stays distinct from<Tab>. Three documented synthetic- modifier drift cases vs. legacy (<S-Esc>,<C-Esc>,KeyCode::Tab + SHIFT) -- terminals don't emit these in practice; the drift test allow-lists them. input::translate's early-out branches fortranslate_insert_completion_popupandtranslate_active_snippetare gone; theModalState::Insertarm now callsdispatch_insert(ctx.keymap, &event, ctx.pending)and the merged trie handles overlay precedence.- The test fixture in
input::testsnow picks among shared scenario-specific keymaps (shared_keymap_base,shared_keymap_with_popup,shared_keymap_with_snippet,shared_keymap_with_both_overlays) so each test's dispatcher sees the right layer stack.
Slice 8.g -- Migrate Normal mode
The big one. Likely sub-sliced:
- 8.g.i -- single-chord motions and operators (
j,w, pseudo-operatorsD/C/S/Y/x, mode entry, paste, search nav, viewport jumps, etc.). ✅ landed: seekeymap_normal::register_normal_bindings/lookup_normal. The legacyinput::translate_normalkeeps its match arm for the bindings still pending migration (operator-leadingd/c/y/>/<, pending-prefixg/z, find-char, marks, register, macro control); subsequent sub-slices shrink that arm to nothing. Doubled-operator forms (dd,yy,cc,>>,<<) stay with the existing operator-pending state machine until 8.g.iii -- the architecture-doc bullet groups them with 8.g.i for conceptual completeness, but they ride the operator-pending trie expansion in 8.g.iii. - 8.g.ii --
g_andz_family. ✅ landed. Two-key chord paths registered as[g, X]and[z, X]in the Normal-mode trie.[g]and[z]themselves stay partial nodes (no terminal binding);lookup_normaltranslatesLookupResult::Partialon those chords intoSetPending(AfterG)/SetPending(AfterZ). The second key resolves throughkeymap_normal::lookup_normal_two_key, which the existingPending::AfterG/Pending::AfterZarms ininput::translate_normalnow call. The legacyresolve_after_g/resolve_after_zhelpers are gone. Bindings covered:gg(typed Invoke),gU/gu/g~(case-operator pending),gv,gJ,g;/g,,gd/gD/gy/gI/gr(LSP),zz/z.(center),zt/z<CR>(top),zb/z-(bottom),zf,zo,zc,za,zR,zM,zd,zj,zk,zi. - 8.g.iii -- operator-pending → motion / text-object trie expansion. ✅ landed. Each operator's resolution table is registered under its primary chord prefix:
[d, X]/[c, X]/[y, X]/[>, X]/[<, X]for the single-chord operators, and[g, U, X]/[g, u, X]/[g, ~, X]for the case operators (extending the 8.g.iig_paths). Each table contains: motion targets (typedInvoke(op, Target::Motion(...))), the doubled-operator linewise form (Invoke(op, Range::CurrentLine)--dd,cc,yy,>>,<<,gUU,guu,g~~),i_/a_text-object pendings (SetPending(AfterTextObject)) plus their depth-3 resolutions (Invoke(op, Target::TextObject(...))fordiw,daW,dab, etc. with all aliases), andf/F/t/Tfind-char pendings (SetPending(AfterFindChar { operator: Some(op) })-- the third-key resolution stays in legacyresolve_after_find_charuntil 8.g.v). ThePending::AfterOperatorandPending::AfterTextObjectarms ininput::translate_normalcalllookup_normal_with_prefix(handle, &prefix, event), computing the prefix from the operator id viakeymap_normal::operator_prefix. The legacyresolve_after_operatorandresolve_after_text_objectfunctions are gone, and the operator-leading single keys (d/c/y/>/<) moved out oftranslate_normal's match arm into the trie's depth-1 layer (terminal Bound nodes that armPending::AfterOperator). - 8.g.iv -- count accumulator (§7.1). ✅ landed. The digit accumulator stays App-side (
pending_count,op_count-- updated by thePushDigithandler and theSetPending(AfterOperator)transition). The multiplication moves tokeymap_normal::attach_count, applied at the tail ofinput::translate_normalso everyAction::Invokeleaving translate carries the resolved count (op_count * motion_count, withmotion_countfalling back toinv.count.unwrap_or(1)-- the binding's registered default, e.g.<PageDown>'sCount(10)-- when the user hasn't typed a digit). App's existing count-multiplication math inrun_document_invocation/run_read_only_motionstays for now -- it's idempotent against translate's attach (same inputs, same result) and serves the few internal callers that build invocations without going through translate (do_repeat_find, etc.). 8.i can collapse the duplication once those callers route through a shared attach helper. - 8.g.v -- mark / register / find-char wildcards (§7.3). ✅ landed. Each prefix chord (
m/'/`/"/q/@/f/F/t/T) registers a depth-1 terminal binding that arms the matchingPending::After*state, plus a depth-2 child via [ChordPattern::CharLiteral] (the trie's wildcard primitive). The wildcard binding carries a placeholder action (SetMark('\0'),SelectRegister(Register::Unnamed),Invoke(find_char_*, Args::None), ...);keymap_normal::substitute_normal_capturerewrites the placeholder with the captured char before the action leaves translate. Operator-prefixed find-char (d{f|F|t|T}<X>) extends the same mechanism: depth-3 wildcards under each operator prefix resolve toInvoke(op, Target::Motion(find_char_*, Args::Char(captured))). AllPending::AfterSetMark/AfterJumpMark*/AfterRegister/AfterMacroStart/AfterMacroPlay/AfterFindChararms ininput::translate_normalnow calllookup_normal_with_prefixagainst the appropriate prefix; the legacyresolve_after_*helpers are gone. The legacy match arm incompute_normal_actionis gone too -- onlyqwhile macro recording stays as a special-case short-circuit (state-dependent on App'srecording_macro). One documented drift from legacy: the trie'sCharLiteralonly matches bare-printable chords, so e.g.f<C-x>drops the pending state instead of using'x'as the find target. Terminals don't typically emit such chord combinations. - 8.g.vi --
<C-w>window-management sub-tree. ✅ landed. Closes Normal mode out. Every CTRL chord (<C-d>/<C-u>/<C-f>/<C-b>/<C-e>/<C-y>/<C-r>/<C-o>/<C-i>/<C-t>/<C-l>/<C-v>/<C-q>) now lives at depth 1 in the Normal trie -- the legacy CTRL guard at the top ofcompute_normal_actionis gone.<C-w>is a terminal-with-children: depth-1 bindsSetPending(AfterCtrlW); depth-2 covers both bare (<C-w>w/<C-w>l/ ...) and ctrl-modified (<C-w><C-w>/<C-w><C-l>/ ...) second-key forms, including the<Tab>/<S-Tab>(=BackTab) /<Backspace>/ arrow aliases vim's lenient<C-w>prefix accepts. ThePending::AfterCtrlWarm ininput::translate_normalcallslookup_normal_with_prefix(handle, &[KeyChord::ctrl('w')], event);resolve_after_ctrl_wis gone. After this slicecompute_normal_actionreduces to: pending resolution -> digit prefix -> recording-qshort-circuit ->lookup_normal. The legacy match arm is empty; the function is essentially a thin orchestrator around the registry.<C-c>(universal Quit) is intentionally not registered in the trie -- it's intercepted byinput::translatebefore mode dispatch. The<C-c>branch of legacyresolve_after_ctrl_w's ctrl table was unreachable in practice; its registration in the trie is kept for parity with the legacy reference but produces the same unreachable behaviour.
Each sub-slice ships independently green; the drift test is the regression net.
Slice 8.h -- Plugin / user-config integration ✅ landed
The plugin host (WASM Component-Model) is post-1.0 (see design.md §13 roadmap), so this slice ships the registry-side infrastructure every future host integration sits on top of:
KeymapCapabilityenum inkeymap_registry.rs-- the privilege bundle a writer presents when calling the gated bind APIs. Variants mirror the WIT spec (design.md §5.5):Full-- unrestricted; reserved for the host's startup catalog enumeration.User-- writes toKeymapLayer::Useronly;init.rsruns with this.MinorMode-- writes to anyKeymapLayer::MinorMode(_)/KeymapLayer::Bufferlayer; plugins withkeymap-write:minor-modein their manifest receive this.OwnedLayer { layer_id }-- writes to a single specifiedMinorModelayer; mirror of WITkeymap-write:plugin-layer.
KeymapErrorwithCapabilityDenied { capability, layer }andInvalidChord(ChordParseError)variants;Display+Errorimpls so the future host can surface the failure to the plugin / user verbatim.KeymapHandle::try_bind,try_unbind,try_push_layer-- capability-gated wrappers that funnel through a singlecapability_allowscheck before delegating to the un-gatedbind/unbind/push_layer. The un-gated variants stay public; the host startup pass uses them for the built-in catalog.KeymapHandle::try_bind_chord_string-- WIT-shaped convenience that parses a chord-sequence string ("<C-w>j","gd") intoVec<ChordPattern::Literal>before delegating totry_bind. The future WITbindhost-fn calls this; userinit.rscalls a thin wrapper around it.- Tests covering the architecture-doc enumeration:
plugin_binds_chord_that_fires_plugin_commanduser_remaps_dd_and_overrides_builtin(the "survives a restart" shape, simulated as "user override stays authoritative across intervening writes" -- persistence isn't a registry concern; init.rs re-runs at boot).conflicting_plugins_resolve_via_layer_priority(twoOwnedLayercapabilities binding the same chord; later push wins; popping it restores the older).- Capability-denial tests for every (capability, layer) pair in the matrix: User vs Builtin / MajorMode / MinorMode / Buffer; MinorMode vs Builtin / MajorMode / User; OwnedLayer vs other-id MinorMode / Builtin.
try_bind_chord_stringparses<C-w>jand the binding fires; an unterminated angle-bracket surfaces asKeymapError::InvalidChord.
- WIT interface (
wit/keymap.wit) is deferred until the WASM host lands. The capability + error types in this slice are the in-process shape; once the host arrives, the WIT functions translate plugin manifest declarations intoKeymapCapabilityvariants and call throughtry_*. init.rsAPI is similarly deferred. The shape will be a thin wrapper overtry_bind_chord_string(with command resolution via theCommandRegistryby name); writing it meaningfully requires the WASM init-module loading machinery that doesn't exist yet.
Slice 8.i -- Retire the bind_legacy bridge
The full approach memo lives at docs/../archive/8i-approach.md: goal, the Effect::AppAction(AppEffect) carrier shape (option α), the type-hoisting decisions, and the sub-slice plan.
Sub-slice status:
- 8.i.0 -- Carrier + dispatcher's Action branch. ✅ landed.
- Adds
lattice_grammar::AppEffect(initial variant:Quit). - Adds
Effect::AppAction(AppEffect). - Adds
ActionSpec/ActionContext/register_action/require_actionparallel to the existing motion / operator / text-object / ex-command spec machinery. - Replaces
CommandRegistration::StubwithCommandRegistration::Action(ActionSpec). - Wires
CommandKind::Actionindispatcher::execute(was anInvalidArgsstub). App::apply_effectgains anEffect::AppAction(app)arm delegating to a newApp::apply_app_effect(app)method.- Tests:
dispatcher::tests::execute_routes_action_kind_to_action_spec(carrier flows throughexecute()and surfaces the spec's Effect) +action_branch_rejects_non_action_entries(require_action's kind-mismatch path). - No call-site changes; bridge stays active. Workspace tests green: lattice-grammar 184 → 186; all other crates unchanged.
- Adds
- 8.i.1 -- Promote no-payload Action variants. ✅ landed across 8 sub-batches (8.i.1.a-h). 41 distinct AppEffect variants promoted; ~85
bind_legacycall sites swapped tobind.register_normal_bindings/register_visual_bindings/register_insert_bindings/register_replace_bindingsall now takeactions: &ActionIds. Drift-test reference bodies in Visual and Replace updated in lockstep with their respective variant migrations. Workspace tests stay green at every batch boundary; lattice-ui-tui at 1328 throughout.- 8.i.1.a --
%,~,o,O,K(5 variants). - 8.i.1.b -- search + history:
n,N,<C-o>,<Tab>/<C-i>,g;,g,,<C-t>(7 variants). - 8.i.1.c -- fold ops:
zo,zc,za,zR,zM,zd,zj,zk,zi(9 variants). - 8.i.1.d -- edit history + scroll:
u,<C-r>,.,<C-f>,<C-b>,<C-y>,<C-e>(7 variants). - 8.i.1.e -- misc viewport / entry / paste:
<C-l>,:,-,gv,p,P(6 variants). - 8.i.1.f -- LSP go-tos:
gd,gD,gy,gI,gr(5 variants). - 8.i.1.g -- final no-payload:
a,zf,<BS>(insert),<C-Space>/<C-x><C-o>,<C-x><C-s>(5 variants). - 8.i.1.h -- drift-body migration: visual
<Esc>/v/V, replace<BS>(2 variants).dispatch_replacegained itsInvokefallback alongside this batch.
- 8.i.1.a --
- 8.i.2 -- Promote parameterised Action variants. ✅ landed across 5 sub-batches (8.i.2.a-e). 22 distinct CommandIds promoted; ~28
bind_legacycall sites swapped tobind. Encoding convention: distinctCommandIdper param value; AppEffect carries the typed param when the param type lives in (or is hoisted into)lattice-grammar. App'sapply_app_effectmatches a single arm per AppEffect variant (e.g.EnterMode(state)->self.apply(Action::EnterMode(state))) rather than N flat variants. Slice 8.i.2.c hoistedViewportPosandScrollPosfromlattice-ui-tuiintolattice-grammar/src/app_effect.rs; the App keepspub usere-exports of both so existingcrate::app::ViewportPos/crate::app::ScrollPoscallers stay compiling.- 8.i.2.a -- mode entry (6 IDs):
EnterMode(Insert/Normal/ Replace)+EnterVisual(Charwise/Linewise/Blockwise). - 8.i.2.b -- search (4 IDs):
EnterSearch(_)(//?) +SearchWordUnderCursor(_)(*/#). - 8.i.2.c -- viewport (6 IDs, type hoist):
JumpViewport(_)(H/M/L) +ScrollCursorTo(_)(zt/zz/zband aliases). - 8.i.2.d -- operators (4 IDs):
JoinLines{with_space}(J/gJ) +FindRepeat{reverse}(;/,). - 8.i.2.e -- insert literals (2 IDs):
InsertNewline(Insert- Replace
<CR>) +InsertTab(Insert<Tab>).
- Replace
- 8.i.2.a -- mode entry (6 IDs):
- 8.i.3 -- Wildcard-captured variants. ✅ landed as one commit. Seven captured-char wildcards promoted in a single batch (the seam was small enough that splitting wouldn't have helped review):
OverwriteChar,SetMark,JumpToMarkLine,JumpToMarkExact,SelectRegister,StartMacroRecord,PlayMacro(withPlayLastMacroas the@@branch of the sameplay-macroaction). Architecture call: validation lives in the boundActionSpec, not the per-mode dispatcher. The dispatcher just foldsArgs::Char(captured[0])into the invocation; each spec's apply closure validates per-variant rules and either emits the typedAppEffector returnsEffect::None(the no-op effect IS the "drop pending" signal, sinceApp::applyclears pending on every non-SetPending(_)action).Register::from_input_charwas added tolattice-grammaras the canonical char-to-Register mapping shared by the App and theselect-registerspec. The Replace drift comparator'sInvokearm now comparesargsso captured-char substitution regressions trip the test. - 8.i.4 -- Retire Pending; finalise. ✅ landed.
- 8.i.4.a -- Scaffold + 9 simple-prefix migration. ✅ landed. Adds
App::partial_chord: Vec<KeyChord>andAction::AbsorbPartialChord(KeyChord);lookup_normalemits the latter on every triePartial. The 7 prefix-onlybind_legacy([m / ' // " / q / @ /], SetPending(After*)) sites are gone (the trie's naturalPartialhandles them);gandzlikewise stop synthesisingSetPendingfromlookup_normal'sPartialarm.App::applyclearspartial_chordon every non-AbsorbPartialChord()action, mirroring the existingpendingreset on every non-SetPending()action.compute_normal_actiongains a top-level partial_chord short-circuit that wins over thematch pendingbody. The 9 migratedmatch pending` arms are unreachable post-migration but kept as a defensive no-op until 8.i.4.c retires the Pending enum.- 8.i.4.b -- AfterCtrlX (Insert mode). ✅ landed.
dispatch_insertgrows apartial_chordparameter; the trie'sPartialfor<C-x>(because[<C-x>, <C-o>]and[<C-x>, <C-s>]are bound) emitsAction::AbsorbPartialChord(<C-x>)instead of the priorSetPending(AfterCtrlX)synthesis.- 8.i.4.c -- AfterOperator + AfterTextObject + AfterFindChar. ✅ landed. The 10 remaining
bind_legacy(... SetPending(After*))sites retire. AfterOperator (8 sites) needs op_count latching that the trie's plainPartialdoesn't carry, so a newAppEffect::AbsorbOperatorPrefix(OperatorId)variant handles both atomic effects (latchpending_count->op_count, push prefix topartial_chord); each operator binds a typedCommandInvocationwhoseActionSpecreturns this variant. AfterTextObject and AfterFindChar delete cleanly -- their[op, i / a]and[op, f / F / t / T]paths are naturalPartialnodes once the standalone binds are gone.lookup_normal_with_prefix'sPartialarm now emitsAbsorbPartialChord(chord)(wasSetPending(None)), required for nested partials like[d, i]and[d, f].compute_normal_action'smatch pendingbody collapses to one defensive no-op covering all 13 Pending variants.actions::populategrew a&Builtinsparameter so the operator-prefix helpers can capture theOperatorIdin their closures.- 8.i.4.d -- Pending retirement +
<C-w>sub-tree. ✅ landed.App::pending: Pendingfield,Pendingenum,Action::SetPending(_)variant,compute_normal_action'smatch pending {}body, and thepending: Pendingparameter onTranslateContext/translate_normal/compute_normal_action/dispatch_insertare gone.App::apply's "non-SetPending clears pending" guard retired. The 2 remainingbind_legacy(... Action::*Pane*, ...)sites inregister_ctrl_w_sub_treemigrated to typedCommandInvocations with 6 new App-side actions (split_pane_horizontal,split_pane_vertical,close_pane,navigate_pane_{left,down,up,right},next_pane,prev_pane);PaneDirectionhoisted fromlattice_ui_tui::panetolattice_grammar::app_effect(mirroring theScrollPos/ViewportPoshoist in 8.i.2.c).run_invocationgained aCommandKind::Actionshort-circuit: action-kind invocations bypassrun_document_invocation's count multiplication and pending-count reset, otherwise thepending_count = 0;inside it would fire before the dispatchedAppEffect::AbsorbOperatorPrefix(_)could latch op_count, breaking2dw-style flows.- 8.i.4.e -- Retire
bind_legacymachinery. ✅ landed. Migrated the completion-popup and active-snippet minor-mode layer builders to bind typedCommandInvocations throughbind_invocation; promoted 13 popup/snippet variants intoAppEffect(CompletionNext,CompletionPrev,CompletionAccept,CompletionCancel,CompletionCancelAndExitInsert,CompletionToggleDocs,CompletionDocsScrollDown,CompletionDocsScrollUp,CompletionAcceptThenInsert(c),SnippetNextPlaceholder,SnippetPrevPlaceholder,SnippetLeave, plusCompletionTriggeralready migrated earlier). The wildcard<bare-char>chord in the popup layer rides asArgs::Char(c)on thecompletion-accept-then-insertinvocation; control chars are filtered in the spec. With every binding now carrying a realCommandInvocation, theKeymapHandleLegacyExttrait +bind_legacyadapter, theBoundCommand::legacy_actionfield +from_legacy_actionconstructor, thelegacy_action_command_id()placeholder, and thelegacy_action.is_some()branches indispatch_replace/dispatch_visual/action_from_bound_with_captureare deleted. Drift reference bodies inkeymap_replace::tests::legacy_translate_replaceandkeymap_visual::tests::legacy_translate_visual(and their pairedactions_equivalentcomparators + cross-productregistry_dispatch_matches_legacy_translatetests) are deleted as well -- with the legacy bridge gone there is no second path to drift against; the per-bindingmatchtests in each module continue to pin the exactInvoke(...)shape.- 8.i.4.f -- Close the count-flow seam. ✅ landed. Two latent seam bugs surfaced once an end-to-end
key_harness_*press harness landed inapp::tests(drivescrossterm::KeyEventthroughinput::translate+App::apply-- the same pathruntime.rswalks). Both bugs were invisible to the per-layer tests: the translate-side tests assert on the returnedAction, the App-side tests hand-constructAction::Invoke(...); nothing exercised the seam between them.- Bug A: dispatcher double-applied count. After 8.g.iv routed count multiplication into
keymap_normal::attach_count(input-side), the App-side math inrun_document_invocationandrun_read_only_motionwas never removed. Withattach_countbakingCount(N)into the inv and the dispatcher then computingop_count.saturating_mul(motion_count)over that already-multiplied count,2ddran withop*motion = 2*2 = 4and3ddsaturated the buffer at 9. Fix: drop the dispatcher-side multiplication; dispatcher now readsinv.countdirectly and drainspending_count/op_countfrom App state. The three App-layer tests that bypassedattach_countby manually feedingAction::PushDigit + Invoke(operator) + Invoke(motion)were migrated to bake the right count into the invocation themselves -- mirroring whatattach_countwould produce. - Bug B: digit eaten in operator-pending state.
compute_normal_actionchecked thepartial_chordshort-circuit BEFORE the digit handler. Afterdabsorbed intopartial_chord=['d'], typing2routed tolookup_normal_with_prefix(['d'], '2')-- unbound -- which returnedAction::Noneand silently aborted the operator (App's partial_chord-clear guard at the top ofapplyate the prefix). Vim'sd2w,2d3w,5ggcould never fire because the digit never reached thepending_countaccumulator. Fix: hoist the digit handler above thepartial_chordshort-circuit incompute_normal_action. Safe today (no built-in chord has a digit as second key); a future plugin that wants[X, digit]chords would expand the rule to "digit handler unless[partial, digit]is bound", with the registry already carrying the data needed. Coordinated change in App's partial_chord-clear guard:Action::PushDigit(_)is now exempt alongsideAbsorbPartialChord(_), since accumulating a count between chord steps is neither a resolution nor an abort of the multi-key sequence. - The
key_harness_*press tests now pin both fixes:count_after_operator_d2w_deletes_two_words,counts_multiply_on_both_sides, andcount_clears_after_motion_fires. The tests sit alongside the original two sanity tests (j_advances_cursor_one_line,dw_deletes_first_word) plus coverage for the<C-w>action-kind short-circuit (slice 8.i.4.d) and the Insert mode round-trip.
- 8.i.4.g -- Linewise-operator newline semantics. ✅ landed. The press harness
key_harness_count_before_ operator_dd_deletes_n_lines(deferred from 8.i.4.f) surfaced a third bug -- distinct from the count-flow seam -- in the linewise operators themselves. Vim'sddconsumes the line AND its trailing newline so the line count drops bycount; lattice'sRange::CurrentLinereturned just the line content[(line, 0), (line, line_len)], soddleft a phantom empty line behind. Same bug foryy: the yanked content was"BBB"instead of vim's"BBB\n", breaking paste-after-linewise round-trips.cc,>>,<<were already correct --cc's "delete content + enter Insert" produces vim's "delete line + reinsert blank line" by byte coincidence (Range::CurrentLine's byte span across multiple lines does include interior newlines, just not the trailing one);>>/<<deliberately operate on content only.- Fix lives operator-side, not in the shared
Range::CurrentLineresolver, so>>/<</cckeep their current ranges. New helperextend_linewise_range(buffer, range)inlattice-grammar::builtins: extends the range to consume the trailing newline; on the last line (no trailing newline available) consumes the LEADING newline instead so the previous line doesn't gain a phantom newline; whole-buffer ranges pass through unchanged. operator_deleteuses the extended range as both the slice source and the edit range, so the register content and the buffer-side delete agree.operator_yankdoesn't walk the buffer for the newline -- linewise yank content always ends with\n(vim clipboard convention), so yank just appends\nto the original slice if missing. That gives the same"BBB\n"whether the source line had a trailing newline or not.- Test fallout: 3 grammar tests + 6 UI-layer tests pinned the old "leaves an empty line" /
"BBB"shapes; updated to the vim-correct shapes ("aaa\nccc"and"BBB\n"respectively). The stale "existing app contract" comment atdd_on_non_fold_line_uses_count_oneis gone -- the contract is now "matches vim".
- 8.i.4.h -- Wrap-up: catalog⇄registry test, dispatcher end-to-end bench, cross-refs. ✅ landed. Three small threads bundled into one slice so the four-artefact rule lands as one cohesive unit.
- Test: new
every_catalog_command_resolves_in_registryininput.rs's test module asserts everydefault_keymap()entry withcommand: Some(name)resolves to a real registry entry. Catches descriptor drift the existingkeymap_descriptors_dont_drift_ from_translatetest misses -- the chord-side check verifieschord -> Actionround-trip; the new check verifiesdescriptor.command -> CommandRegistryconsistency. They're complementary, not competing. - Drift-test retirement deferred. The wrap-up plan originally called for retiring the chord-side test ("descriptor IS behaviour"). Held off: that's only true once
default_keymap()is the trie's source-of-truth, which is the post-1.0 promotion slice. Until then, the chord-side test still catches bugs the registry-side check can't (chord notation drift, mode mis-routing). Both stay; one retires when source-of-truth promotion lands. - Bench: two new
dispatch_translate_full_*rows inbenches/keymap.rsmeasuring fulltranslate()round-trip through the production-wired keymap (realregister_*_bindingscalls -- not the syntheticpopulated_handle()the trie-only rows use)._two_chordcovers the partial_chord dispatch (gd);_operator_motioncovers the operator-prefix flow that 8.i.4.c rebuilt (dw). Both ~100 ns -- ~10× under the 1 µs keystroke commitment, ~3× the bare trie-lookup numbers (the remainder is dispatcher fan-out + Action construction). docs/../operations/benchmarks.md: rows added + a slice-8.i narrative paragraph confirming the dispatcher rebuild stayed in budget; AbsorbPartialChord / AbsorbOperatorPrefix short-circuits don't measurably hurt the hot path.docs/design.md: cross-references added to §5.2.4 (Extensibility) and §5.11 (Introspection). §5.2.3 already pointed at the architecture doc; §5.2.4 now points at §5 / §6 here for keymap-side extension mechanics, and §5.11 points at §3.5 / §6 for the typed-descriptor metadata surface that backs:describe-key.
10. Trade-offs flagged
- Operator-pending as trie state vs. separate mode. The trie encoding (§7.2) is cleaner architecturally but means the dispatcher tracks "I'm at a partial trie node" as state instead of "I'm in a
BindingMode::OperatorPendingmode". ExistingPendingenum stays, repurposed: it now carries the partial trie cursor + count. Net win in clarity once the migration completes; some churn in the App's apply path. - Layer-merge cost on every push/pop. Pushing a minor mode rebuilds the merged trie. With ~hundreds of bindings per layer and ~5 layers active, that's a sub-millisecond hit on mode transitions (rare). The alternative -- walk layers separately on each lookup -- pays the cost on every keystroke instead. Merge-on-write is the right trade-off.
Actionenum collapse. Today input.rs returnsActionvariants; some encode mode transitions, App-thread effects, not just command invocations. Migration may need to keep a thinActionenum during the transition (slices 8.d-g); it dissolves once every binding routes throughCommandInvocation. Track where this lands at slice boundaries.- Char-literal wildcards in the trie. Adding
CharLiteralchild variants (§7.3) is a small structural extension to the trie; it does not affect the literal-chord lookup path. Bench on slice 8.b confirms.
11. Mode-owned keymap contributions (substrate gap)
Sections 5.2--5.3 describe major / minor mode bindings as "host registers when a major mode activates" / "pushed when a minor activates." The registration path was specified; the declaration path -- how a
Modeimpl in another crate expresses what its bindings are -- was never wired. Thelattice-mode::contributions::Keymapstub (crates/lattice-mode/src/contributions.rs) currently carries a_private: ()field with a TODO comment: "the placeholder lets modes declare intent to contribute a keymap layer without forcinglattice-modeto depend onlattice-grammar."Until this gap closes, every mode that ships bindings ends up registering them in
lattice-hostvia a hand-rolled<mode>_keymap.rs(multibuffer_keymap.rs, the LSP / Oil / Snippet bindings underKeymapLayer::Builtin). This contradicts the mode-ownership convention (feedback_mode_owns_its_surface) and blocks the MO.1--MO.4 cleanup slices.11.1 Substrate placement
The chord-substrate moves down the dependency graph so modes in any crate can construct bindings:
Type Today (host-owned) Target crate Why KeyChord,KeyKind,KeyMods,ChordPatternlattice-host::chordlattice-protocolRenderer-neutral wire data; same shape class as BufferId/Position/Selection. Renderers produce them, dispatchers consume them.BindingModelattice-host::keymaplattice-modeVim-modal-state enum; semantically part of the mode subsystem. Keymapcontribution typestub in lattice-mode::contributionslattice-mode(real)Returned by Mode::keymap().KeymapTrie,KeymapLayer,BoundCommandlattice-host::keymap_triestay in host Matcher engine + layer resolution; implementation detail of the lookup hot path. lattice-runtimewas considered and rejected: it owns async substrate (EventBus, Document spawning, snapshots), and chord primitives have no runtime characteristics. Coupling them into runtime would be by accident, not by meaning.Adding
lattice-grammaras alattice-modedep is permitted:lattice-grammardoes not depend onlattice-mode, so no cycle. The previously-cited reason for the stub deferral is resolved by accepting the dep.11.2 The real
Keymapcontribution type// crates/lattice-mode/src/contributions.rs pub struct Keymap { pub bindings: Vec<KeymapBinding>, } pub struct KeymapBinding { pub mode: BindingMode, pub chords: Vec<ChordPattern>, pub command: CommandInvocation, pub source: SourceLocation, }Modes return
KeymapfromMode::keymap(). The contribution is declarative -- same value returned every call -- so the host can ask once at registration time and cache the result. Layer is implicit: every binding contributed byMode Xlives atKeymapLayer::MinorMode(x.id())(which covers both majors and minors per K.1.b convention).11.2.1 Ergonomic surface:
Keymap::bind_chordThe raw struct is the floor. The recommended idiom for mode-contributed keymaps reads like a binding table without manual chord-vector construction or
SourceLocationplumbing per row:impl Mode for MultibufferMode { fn keymap(&self) -> Keymap { Keymap::new() .bind_chord(BindingMode::Normal, "]e", self.commands.excerpt_next) .bind_chord(BindingMode::Normal, "[e", self.commands.excerpt_prev) } }bind_chordis#[track_caller]soLocation::caller()captures the binding row's ownfile:lineinto the resulting [SourceLocation].:describe-keyshows the chord's declaration site directly; noSourceLocation::builtin_file(file!(), line!())boilerplate per row. (Boilerplate that would also rot: a row's hand-writtenline!()drifts every time a row above is inserted or deleted.)The chord string is parsed via the existing [
lattice_protocol::parse_chord_sequence] -- same notation the host'skeymap_entry!catalog uses. Multi-chord paths work uniformly across vim and emacs idioms:chord string parsed sequence typical use "j"[char('j')]vim motion "gd"[char('g'), char('d')]vim go-to-def "]e"[char(']'), char('e')]multibuffer next-excerpt "<C-w>j"[ctrl('w'), char('j')]vim split-down "<C-w>gd"[ctrl('w'), char('g'), char('d')]vim split-goto-def "<C-x>pp"[ctrl('x'), char('p'), char('p')]emacs project-switch "<C-x><C-s>"[ctrl('x'), ctrl('s')]emacs save-buffer "<C-c><C-c>"[ctrl('c'), ctrl('c')]mode-confirm "<S-Tab>"[shift(Tab)]snippet-prev "<lt>"[char('<')]literal <The trie indexes the chord sequence directly. Pressing
<C-x>after abind_chord(Normal, "<C-x>pp", …)givesPartial; pressing the secondpgivesBoundand fires. No "after-C-x" binding-mode state is required -- the matcher walks the sequence generically. (This is the same trie discipline that has always supported vim'sgg; the K.2.3 surface just makes the emacs-prefix shape ergonomic to declare from a mode crate.)Boot-fail on malformed chord strings. Mode bindings live at compile-time-static call sites with constant strings; a parse error is a bug in the mode impl, not a runtime condition.
bind_chordpanics with a message naming the chord string and the caller location. The editor refuses to boot rather than silently dropping the binding -- same discipline the host'skeymap_entry!catalog uses for the identical reason.What
bind_chorddeliberately does not express: wildcard slots ('amark target,"aregister name,fXfind-char target,qamacro-name,@amacro-play target). The shorthand has no notation for "any single typed char"; the rare mode that needsChordPattern::CharLiteralcalls [Keymap::bind] with an explicitchordsvector. Today every wildcard-bearing binding lives in the host's built-in catalog (Normal-mode marks / registers / find-char / macro prefixes), so mode crates almost never need the lower-level API.Plugin-loaded bindings, runtime
:bindinvocations, and test fixtures use the lower-level [KeymapBinding::new] constructor directly with whateverSourceLocationis appropriate (SourceLayer::Plugin(...),SourceLayer::Runtime, …).bind_chordis the affordance for the built-in-Rust idiom; it does not own the contract.11.2.2 Table-form contribution:
Keymap::from_entriesThe chain form is ergonomic for 1-5 bindings. Mode crates that ship 5-20+ bindings (LSP majors / minors, multibuffer, oil, snippet, file-tree, …) read better as a table the eye can scan top-down. K.2.4.A.0 promoted the host's
keymap_entry!macro out oflattice-hostand intolattice-modeso any mode crate can declare bindings as static-catalog rows:use lattice_mode::{ keymap_entry, BindingMode, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeKind, ModeId, }; use std::sync::OnceLock; fn multibuffer_keymap_entries() -> &'static [KeymapEntry] { static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new(); ENTRIES.get_or_init(|| { vec![ keymap_entry! { mode: Normal, chord: "]e", doc: "Jump to next excerpt", cmd: "multibuffer:excerpt-next" }, keymap_entry! { mode: Normal, chord: "[e", doc: "Jump to previous excerpt", cmd: "multibuffer:excerpt-prev" }, keymap_entry! { mode: Normal, chord: "]E", doc: "Jump to next file boundary", cmd: "multibuffer:file-next" }, keymap_entry! { mode: Normal, chord: "[E", doc: "Jump to previous file boundary", cmd: "multibuffer:file-prev" }, ] }) } impl Mode for MultibufferMode { fn keymap(&self) -> Keymap { Keymap::from_entries(multibuffer_keymap_entries()) } // ...id(), kind(), on_activate(), ... }Each
keymap_entry!row carriesmode,chord(notation string),doc(one-line summary), andcmd(canonical command name registered in theCommandRegistry). The macro capturesfile!()+line!()per row via a#[doc(hidden)] pubconstructor (KeymapEntry::__new) so the row's declaration site flows through to:describe-key's source link without any per-row boilerplate.The table form expands the [
Keymap] contribution shape with a second field:pub struct Keymap { pub bindings: Vec<KeymapBinding>, // chain form pub entries: Vec<&'static KeymapEntry>, // table form }Both fields populate the same matcher trie; what differs is the resolution timing.
Chain form:
bindingsalready carries a typedCommandInvocation, andbind_chordparsed the chord string at call time. The host pass inserts directly into the trie.Table form: each
entrycarries a&'static strcanonical command name ("multibuffer:excerpt-next"); the host pass walksentriesat registration time, looks up each name in theCommandRegistryto mint aCommandId, parses the chord string into aVec<ChordPattern::Literal>, builds aKeymapBindingcarrying the entry'sdoc+source, and inserts it into the trie alongside the chain-form bindings.The two paths compose freely:
fn keymap(&self) -> Keymap { Keymap::from_entries(multibuffer_keymap_entries()) .bind_chord(BindingMode::Normal, "<C-r>", self.refresh_cmd.clone()) }The
<C-r>binding doesn't fit the catalog becauseself.refresh_cmdis built at mode-construction time (not a static name), so it routes through the chain form; the rest of the bindings ride the table.Per-entry resolution behaviour (translation pass
crates/lattice-host/src/keymap_mode_contributions.rs):Entry shape Pass behaviour command = NoneSilent skip. Synthetic catalog row (PushDigit / SetPending / find-char prefix / register prefix / mark-name prefix) — informational for :describe-keyand:keymap, not dispatchable.command = Some(name), registry lookup misstracing::warn!and skip. Catalog drift — the binding names an invocation no command implements. Matches existing catalog drift-test convention.Chord string fails parse_chord_sequencetracing::warn!and skip. Defensive — thekeymap_entry!macro doesn't validate chord strings at expansion; a typo lands here.Otherwise Build KeymapBinding::new(mode, parsed_chords, CommandInvocation::of(cmd_id), entry.source().clone()).with_doc(entry.doc)and feed into the same grouping pass the chain form uses.When to pick which form:
Form Picks up Best for Chain form ( bind_chord)Typed CommandInvocation(no registry lookup), no per-binding doc,#[track_caller]source capture1-5 bindings; dynamically-named commands the mode itself constructs; per-binding parameterisation (counts, args) Table form ( from_entries)&'static strcommand name (looked up at registration), per-rowdocstring surfaced by:describe-key, macro-captured source per row5-20+ bindings against the standard command vocabulary; reads as a binding-table; static catalog for :keymapThe two paths converge at the same
KeymapTrieat the sameKeymapLayer::MinorMode(mode.id()), so they're indistinguishable from the matcher's point of view.Sub-arc landed (2026-06-02): K.2.4.A.0.1 substrate move (commit
4f763d5); K.2.4.A.0.2Keymap::from_entries+KeymapBinding.doc(commit6461f56); K.2.4.A.0.3 host translation pass entry resolution (commit81c4600); K.2.4.A.0.4 chain + table composability test (commiteaf9e33).11.3 Host translation
Host gains one new pass in the boot path (and on every dynamic
ModeRegistry::registerafter boot):for mode in mode_registry.iter() { let layer = KeymapLayer::MinorMode(mode.id()); for binding in mode.keymap().bindings { let bound = BoundCommand::from_invocation( binding.command, binding.source, layer, ); host_keymap.trie_for(binding.mode).insert(&binding.chords, Arc::new(bound)); } }The existing
KeymapTrie::insertis the only matcher touch point; the K.2 work is plumbing to it, not changing it.11.4 Effect on §2's five-layer model
No change to the layer model. Mode-contributed bindings still land at layer 2 (major) / layer 3 (minor). The substrate moves who builds them (host today → owning mode crate tomorrow); resolution is unchanged.
11.5 Migration sequencing
See slice plan: keymap-substrate for the K.2.1--K.2.7 carving. The K.2 substrate unblocks the MO.1--MO.4 cleanup slices (LSP, Oil, Snippet bindings move out of host) and the
multibuffer_keymap.rsdeletion that M.6 left in host as known debt.12. Help-prefix bindings (
<C-h>map)Lattice ships an emacs-style help prefix on
<C-h>in Normal mode only. The prefix is Layer 1 (built-in defaults), not a mode contribution -- help is universal, not a state-machine transition. Lives in a host-owned helper module (crates/lattice-host/src/keymap_help.rs) per the slice plan.12.1 Binding table (Normal mode) — landed (K.3, 2026-06-02)
K.3.2 (commit
ed2a1bf) registers these bindings atKeymapLayer::Builtin,BindingMode::Normal, fromcrates/lattice-host/src/keymap_help.rs. The:help-for-helprow is an alias of:helpadded by K.3.1 in the host's ex-command alias table.Chord Command Notes <C-h> <C-h>:help-for-helpExplicit form. Two presses of <C-h>open the help-for-help index.<C-h> ?:help-for-helpEasier-to-type alias of <C-h><C-h>— single keypress after the prefix.<C-h> k:describe-keyPrompts for chord; shows the bound command + provenance. <C-h> c:describe-commandLetter cchosen over emacs'sf(function) -- matches Lattice vocab.<C-h> o:describe-optionLetter ochosen over emacs'sv(variable) -- matches Lattice vocab.<C-h> e:describe-eventLattice-native; emacs has no first-class event introspection. <C-h> m:describe-active-modesActive major + minors on the current buffer, with the chords each contributes. See §12.5. <C-h> M:describe-modePrompts for a mode name ( gen:modescompletion); shows that mode's registry metadata.<C-h> b:describe-bufferBuffer metadata (kind, flags, mode stack, ...). <C-h> a:aproposCross-cutting search across commands / options / events. <C-h> K:describe-bindingsChords that can fire on this buffer (§12.6). Capital K because lowercase kis:describe-key.:keymapremains the full catalog, reachable by name.No bare
<C-h>leaf binding — see §12.3 for the rationale.Correction (2026-08-04). K.3.2 bound
<C-h>mto:describe-mode, whosechordarg isArgDefault::Required. A no-arg invocation therefore armed the interactive arg prompt and asked for a mode name — it never showed the active modes. The row above, its twin inkeymap_help.rs, and theHelpPrefixEntrydoc string all described the intended behaviour rather than the shipped one for ~2 months. §12.5 closes the gap;<C-h>Mpreserves the prompt-for-any-mode path that<C-h>mwas accidentally providing.The
m/Mpair reads listing-then-specific, while the olderk/Kpair reads specific-then-listing. The asymmetry is deliberate: lowercase is the common case in both, andC-h m= active modes is the emacs muscle memory worth preserving (UX-convention rule — muscle memory is the dominant cost on user-facing surfaces).12.2 Out-of-scope binding modes
- Insert mode:
<C-h>retains vim's backspace semantics. No help-prefix in Insert. - Cmdline (the rich minibuffer per design.md §5.9.10): retains its existing
<C-h>(cmdline-level backspace). No help-prefix at the:line. - Visual / OperatorPending: no help-prefix. The two binding modes already carry vim's most chord-dense grammars; keeping the help-prefix Normal-only avoids surprise rebindings and chord collisions in pending state. Users who need help mid-visual escape to Normal first -- vim's mental model.
12.3 No bare
<C-h>leaf — Option 2 from K.3.0 (2026-06-02)The original §12.3 envisioned
<C-h>as both a fireable leaf (:help-for-help) and a prefix to deeper bindings, resolved viatimeoutlen— same machinery vim uses for general chord ambiguity. The K.3.0 trie audit landed a different decision.K.3.0 finding. Today's
crates/lattice-host/src/keymap_trie.rslookup()at lines 251-263 returnsBoundimmediately when a node has a binding, even if it also has children. The dispatcher has notimeoutlenmachinery anywhere — partial- chord state is handled by anAbsorbPartialChord(c)Action that absorbs the chord and waits for the next, but only when the trie returnsPartial(no leaf binding at this node). There's no mid-state "Bound-but-also-has-children" signal the dispatcher could time-out on.Option 2 decision (user-confirmed, 2026-06-02). Skip the bare
<C-h>leaf binding.<C-h>stays a pure prefix node (returnsPartial). The slice plan's already-listed alternatives<C-h><C-h>and<C-h>?serve as the explicit:help-for-helpentry points. One keystroke of extra friction; zero new dispatcher infrastructure.The discarded alternatives were:
- Option 1: Implement full
timeoutlenmechanism. Adds aBound { is_also_prefix: bool }(or a newBoundOrPartial) variant + timer-driven dispatch in the App. ~150-200 LOC acrosskeymap_trie.rs,keymap_registry.rs,input.rs, App state. First piece of timer-driven dispatch in the project. - Option 3: Reverse "leaf wins" — return
Partialwhen a node is both leaf and prefix. Vim-adjacent but not vim-exact (no actual timeout; user must press a deeper chord OR<Esc>to abort to the leaf). Medium effort.
Option 1 is the correct long-term answer when timer-driven dispatch arrives for other reasons (a future
j10jcount- absorbing slice, for example). At that point bare<C-h>can land alongside, and this section retires. Until then, the explicit form is the convention.12.4 Sequencing — landed (2026-06-02)
Full carving in slice plan: help-prefix. K.3 was gated on K.2 landing (it did, commit
0938572closing the K.2 arc) and shipped as a thin slice on top: substrate unchanged, just the new bindings + Option-2 design lock-in + mode-scope tests + docs.12.5
:describe-active-modes— the buffer's own mode stackSequencing, slice IDs and status live in slice plan: describe-active-modes.
The question it answers. "What is this buffer, and what can I press in it?" Distinct from the three neighbours that already exist and stay unchanged:
Surface Question :describe-mode <name>What is this named mode — options, capabilities, active-here? :help <mode-id>What is this mode for — prose, workflows, worked examples. :keymapWhat is the entire default catalog, by binding-mode. :describe-active-modesWhat is live on this buffer, and what does each part bind? Why the major mode alone is not the answer. Asking for "help for the major mode" under-reports by construction, because the standing minor-mode rule (shared behaviour is a minor mode, never a copied keymap) deliberately pushes cross-major chords out of the major. A
magit-refsbuffer getsb/x/<CR>frommagit-refs-modebutgr/q/]]/[[/a/-frommagit-core-mode. A major-only view would omit the half that the architecture just finished centralising. The view is therefore major + minors, not major.Content model. Rows are
[mode-id](help:mode-id)links, so<CR>reaches the prose doc through the existing help-link machinery — no new navigation. Each row carries the mode's help-topic summary when one exists, and the chords the mode contributes, read live:# modes :: magit-refs ## major ◆ [magit-refs-mode](help:magit-refs-mode) b checkout branch x delete reference RET visit reference ## minors (3) ◇ [magit-core-mode](help:magit-core-mode) gr refresh q bury buffer ]]/[[ next/prev a/- stage/unstage ◇ [line-numbers-mode](help:line-numbers-mode) ◇ [wrap-mode](help:wrap-mode)The
◆/◇major/minor glyphs matchbuild_describe_mode_content, and are BMP-block characters per the icon-degradation rule — no Nerd Font required.No new keymap machinery. The chords come from
DynMode::keymap(), already on the trait and already reachable viamode_registry.load().get(mode_id). A trie-side reverse lookup (KeymapLayer→ contributing mode) was considered and is not needed; the mode object already owns the list it contributed. This is the difference between this view and §12.6, which does need the live trie.Both population paths, and the doc-string gap.
Keymapcarries bindings in two slots and the builder must walk both:entries: Vec<&'static KeymapEntry>(thekeymap_entry!catalog form — every entry has adoc) andbindings: Vec<KeymapBinding>(the terse chain form,bind/bind_chord). Walking onlyentrieswould silently under-report every mode that uses the chain form, which is the same class of invisible gap the minor-mode rule exists to prevent.KeymapBinding.docisOption<String>and isNonefor chain-form andKeymapBinding::newbindings — the docs only exist when the binding originated from akeymap_entry!. Rows with no doc render the resolved command name instead of inventing prose:gr refresh ← doc present <C-c>gbb magit-branch ← doc absent, command name shownThis is a real quality signal, not just a fallback: a mode whose rows read as bare command names is a mode that should move to the
keymap_entry!form. The view makes that visible, which is a reason to prefer it over silently omitting undocumented chords.Additive at the plugin boundary.
Effect::DescribeModeis declared inwit/types.witasdescribe-mode(string). Widening it tooption<string>to carry the no-arg case would be a breaking change to a published plugin API, so the no-arg case gets its own additiveEffect::DescribeActiveModes/describe-active-modes()variant instead. Paramount goal #2 over a smaller diff — heuristic #1 cuts the other way here only if you weigh diff size, which it explicitly forbids.Naming, and why the name alone was not enough.
:describe-active-modes, not:describe-modes— the shorter name is a literal prefix-sibling of:describe-mode.That was necessary but not sufficient. Candidate matching on the
:line is fuzzy subsequence, not prefix, sodescribe-modealso matchesdescribe-active-modes(d-e-s-c-r-i-b-e---m-o-d-e is a subsequence of it). Two candidates meantopen_completion_popup'scandidates.len() == 1branch stopped firing and:describe-mode<Tab>no longer stepped into its arg slot — exactly the bugtab_on_complete_command_name_steps_into_the_arg_slotguards, reintroduced from a direction the name choice could not prevent. Every name in thedescribe-*family that mentions modes hits this, so no rename avoids it.The fix is a rule, not a workaround: on the command-name slot, a literal prefix beats a fuzzy subsequence. If exactly one candidate has the typed text as a literal prefix, it is treated as the single candidate. Scoped to
replace_start == 0(the command word starts at offset 0), so file and free-arg slots keep full fuzzy behaviour, where fuzzy is the point. Command names are a closed, known vocabulary — prefix semantics is the vim/readline convention users carry in (UX-convention rule).The rule deliberately requires a prefix, not merely a unique fuzzy hit:
dscrbmodematchesdescribe-modeand nothing else, but still opens the popup rather than rewriting the line. Fuzzy matching asks for confirmation; only prefixes auto-commit.This generalises — any future command whose name fuzzily contains an existing one would have hit the same wall.
Buffer resolution. The builder reads
Editor::active_buffer_id(). Note that the siblingbuild_describe_mode_contentcurrently readsself.document_buffer_idfor its "active on current buffer" line, which is wrong for any non-document buffer (magit, file-tree, help). Corrected in the same slice — it is directly in the path being changed.Degradation. A buffer with no major mode renders the major section as
(none)and still lists minors; a mode id that fails to resolve in the registry is skipped with atracing::debug!, never a panic. Both are reachable states, not defensive padding: synthetic buffers routinely carry minors with no major.No benchmark.
LatencyClass::Display, built on demand, O(active modes) ≈ 5 with no I/O or parsing. The four-artefact rule is satisfied by docs + tests + graceful degradation; a bench here would measure scheduler noise. Recorded as a deliberate omission rather than an oversight.12.6
<C-h>K— buffer-scoped bindings (follow-up):keymaptoday renders the staticentries()catalog (build_list_keymap_content), so it lists every documented builtin binding regardless of what the current buffer can actually fire. That is the right behaviour for a reference dump and:keymapkeeps it.<C-h>Krepoints to a new:describe-bindings(emacsC-h bsemantics) that shows only what is live on this buffer, unioning builtin entries valid in the currentBindingModewith each active mode'skeymap(). It reuses §12.5's active-mode walk, which is why the two land in that order.<C-h>bstays:describe-buffer— in Latticebis buffer-metadata, and the bindings listing hangs offKalongside the other keymap surface.Which
BindingModethe view reports. There is noModalState → BindingModehelper in the tree: the dispatcher branches onModalStateand each per-mode dispatcher hard-codes its own.:describe-bindingstherefore maps them locally, and folds the three minibuffer states (Command,Search,Prompt) ontoNormalrather than reporting cmdline bindings.That is not a shortcut. Those states are how the user typed
:describe-bindings; by the time the view is on screen they are back in Normal, so reporting the:-line's own bindings would answer a question nobody asked.<C-h>Kis Normal-only regardless (the help prefix is), so Normal is the honest answer for both entry points.Insert/Visual/Select/OperatorPending/Replacemap through unchanged, since a mapping bound there is genuinely what would fire.Dead-link guard. Chord cells are
key_linked so<CR>runs:describe-key, except rows whose chord contains aCharLiteralslot — those render as the{char}placeholder, which is not parseable chord notation, so linking them would produce a target that can never resolve. Those rows stay plain text.- K.3.0 ✅ trie audit + Option 2 decision (this section).
- K.3.1 ✅
:help-for-helpex-command alias (commited2a1bf). - K.3.2 ✅
keymap_help.rsregisters the binding table atKeymapLayer::Builtin,BindingMode::Normal(commited2a1bf). - K.3.3 ✅ mode-scope enforcement tests (Visual / OperatorPending / Cmdline / Search / Replace) (commit
05c2e25). - K.3.4 ✅ doc artefacts (this slice).
13.
lattice-keymapcrate + layer-trace API (T1-T13, K.1.c / K.1.d)13.1 Crate extraction (T1-T9) ✅ landed
All keymap types were extracted from
lattice-hostandlattice-modeinto a dedicatedcrates/lattice-keymapcrate. The extraction is a pure mechanical migration — same types, same behaviour, only module paths changed. Every test remained green throughout.Post-extraction dependency graph:
lattice-protocol ↓ lattice-grammar ↓ lattice-keymap ← owns: ModeId, BindingMode, KeymapEntry, keymap_entry!, ↓ Keymap, KeymapBinding, KeymapTrie, KeymapLayer, lattice-mode ← BoundCommand, LookupResult, KeymapRegistry, ↓ KeymapHandle, KeymapCapability, KeymapResolution, lattice-host ← LayerHit, Continuation, parse_describe_key_argTypes that stayed in
lattice-host(circular-dep barrier):Type Reason KeymapReverseLookupHandleImplements lattice-completion::KeymapReverseLookup; pullinglattice-completionintolattice-keymapwould create a cycle.keymap_mode_contributions.rsNeeds both ModeRegistry(fromlattice-mode) andKeymapHandle(fromlattice-keymap); onlylattice-hostcan see both.lattice-modere-exports all moved types fromlattice-keymapfor backward compatibility (callers thatuse lattice_mode::ModeIdcontinue to work).13.2 Layer-trace API (T10-T13, K.1.d) ✅ landed
:describe-keyuses a sparse layer-trace model: instead of a combined static-catalog + runtime-lookup split, one API call walks every registered layer and marks each hit active or not.Types (
crates/lattice-keymap/src/resolution.rs):/// One registered layer's binding for the queried chord. pub struct LayerHit { pub layer: KeymapLayer, pub command: Arc<BoundCommand>, /// `true` if this layer fires for the current buffer. /// Always `true` for Builtin / MajorMode / User / Buffer; /// set by cross-referencing MinorMode id against active_modes. pub active: bool, } /// Full trace for one (chord, BindingMode) pair. /// `hits` ordered lowest → highest priority (Builtin first, Buffer last). pub struct KeymapResolution { pub mode: BindingMode, pub hits: Vec<LayerHit>, } impl KeymapResolution { /// Last active hit (highest-priority active layer). None when /// no active layer has a binding. pub fn winner(&self) -> Option<&LayerHit> { ... } pub fn is_bound(&self) -> bool { self.winner().is_some() } }Mode-prefix parser (also in
resolution.rs):/// Parse a `:describe-key` argument with optional mode prefix. /// Returns (Some(mode), chord_without_prefix) or (None, original). /// /// Prefix table: n_ Normal · i_ Insert · v_ Visual · /// r_ Replace · c_ Command · s_ Search pub fn parse_describe_key_arg(s: &str) -> (Option<BindingMode>, &str)KeymapHandlemethods (inregistry.rs):// Full per-layer trace for one mode + chord sequence. pub fn resolve_trace( &self, mode: BindingMode, chords: &[KeyChord], active_modes: &[ModeId], ) -> KeymapResolution // Run resolve_trace for every BindingMode; omit modes with no hits. pub fn resolve_trace_all_modes( &self, chords: &[KeyChord], active_modes: &[ModeId], ) -> Vec<KeymapResolution>Both are telemetry paths; they take the
innermutex to walk the full layer list and are never called on the keystroke hot path.13.2.a Chord capture — the trie says when a sequence ends (DK.2, 2026-09-07) ✅ landed
:describe-keyhas two entry points and they are complementary, not redundant. This is the vim/emacs split, and lattice keeps both because each answers a question the other cannot:- String form —
:describe-key <chord>, typed as text. Vim's:map <key>. Answers for chords the user cannot press right now, and for bare prefixes::describe-key <Space>reports the leader's continuation subtree (§13.2.b), which no interactive capture can, because pressing it only starts a sequence. - Interactive capture — the
<C-h> kprompt. Emacs'sC-h k. Answers "I pressed this — what happened?", including for keys whose spelling the user does not know, and for keys that turn out to be unbound, which is the most common reason to reach for it.
A chord ARGUMENT is a sequence (
gg,<C-w>v,<leader>fz), so capture cannot submit on the first keystroke. It used to end on an explicit<CR>, with<Esc>(abort) and<BS>(correct) reserved beside it. That terminator cost those three keys entirely: they could never be described, only obeyed. A comment ininput.rsclaimed the missing-arg prompt was an escape hatch; it was not — that path opens the same command line and sets the samechord_captureflag, so it reached the same reserved branch.Both problems come from asking the user to say when a sequence ends. The trie already knows:
PartialversusBound/Unboundis the same question the dispatch loop answers on every keystroke.KeymapHandle::any_layer_expects_more(chords) -> bool // true => some layer, in some BindingMode, returns Partial; keep reading // false => Bound or Unbound everywhere; submit nowSo capture reserves nothing.
gwaits;ggsubmits;jsubmits on one key;<CR>describes Enter;<M-k>that nothing binds submits immediately and says "not bound in any mode".Three properties that are deliberate rather than incidental:
Unboundterminates. "This key does nothing" is a first-class answer — the one a user asking why a key did nothing came for — so treating it as "keep waiting" would hang capture on exactly the query that motivated it.- Any mode, not the current one.
:describe-keyreports across every binding mode, so capture keeps reading while any mode could extend the sequence; otherwise an Insert-only prefix would submit early mid-sequence. - Any LAYER, not the active ones (DK.4, 2026-09-09). The question takes no
active_modesslice, and the omission is load-bearing — see below.
The trade accepted: there is no mid-sequence abort. The sequence ends within a keystroke or two regardless, and dismissing an unwanted description costs one
q. Emacs makes the same trade (C-h k C-gdescribesC-g).Not benched. The lookup runs once per keystroke while a chord prompt is open — never on the editing hot path — and walks the layer stack the same way
resolve_tracealready does per description.Shape is not contextual — DK.4 (2026-09-09) ✅ landed
Capture originally asked the activation-gated form of the question,
any_mode_expects_more(chords, active_modes), withactive_modesread fromApp::active_modes[active_buffer_id()]per the §13.3 rule. That rule is right for dispatch and wrong here, and the difference is which buffer is focused while a chord prompt is open:open_command_linefocuses the*command-line*buffer, so the gated query resolves against the minibuffer's modes. EveryMajorMode/MinorModelayer belonging to the buffer the user came from is invisible to it.The user-visible failure: org's
<C-c><C-x><C-b>(toggle checkbox set) could not be described.<C-c>read asPartialonly because a globally-active minor happens to bind<C-c>g;<C-c><C-x>then had no continuation in that reduced context, so capture submitted and described<C-c><C-x>— a prefix, which at the time rendered as "not bound in any mode". Generalised: no multi-chord binding owned by a major mode or contributed by a plugin could be captured at all.ggworked only because Builtin is always-on, which is why every existing test passed.The rule that replaces it:
:describe-keyanswers for every key, not only the keys active where you stand. Sequence SHAPE is a property of the keymap; what FIRES is a property of the buffer. Only the second one is contextual.So capture keeps reading while any registered binding anywhere could extend the sequence, and
build_describe_key_contentstill resolves activation against the buffer the prompt was opened from (submit callsrestore_editing_buffer()before dispatching, soactive_buffer_id()is the user's buffer again by then), marking each layer[active]/[inactive].Two consequences worth stating plainly:
- A chord that is a prefix anywhere can no longer be captured on its own.
<Space>is the leader, so it waits — as it did before for any buffer where a leader binding was active. The string form covers it, and now answers with the subtree rather than "not bound". - A chord that is bound at its depth still terminates capture even when longer chords exist beneath it, matching dispatch (the trie stops at the first binding and never consults children). Those unreachable continuations are listed in the description, flagged — see §13.2.b.
13.2.b A prefix answers with its subtree (DK.4) ✅ landed
KeymapHandle::continuations(chords, active_modes) -> Vec<Continuation> // every binding registered strictly BELOW `chords`, // across all layers and all binding modes, each carrying // { mode, suffix, layer, command, active }A prefix has no terminal binding, so its layer trace is empty and the honest answer used to be "
<C-c><C-x>is not bound in any mode" — true, and useless when eight chords hang off it.:describe-keynow renders the subtree:<C-c><C-x> is a PREFIX — 8 continuation(s), no binding of its own. ─── Continuations of <C-c><C-x> ─── [Normal mode] <C-c><C-x><C-b> → org-toggle-checkbox-set [inactive] layer: major-mode:org-mode source: …Each row's chord is a
key_link, so<CR>on it re-runs:describe-keyfor that continuation — the listing is a drill-down, not a dead end. Inactive rows are listed rather than filtered, for the same reason capture is activation-agnostic: existence and applicability are different questions, and the reader is asking the first one.The same section renders under a chord that IS bound, with the header naming why those chords do not fire ("reachable only where the binding above is inactive"). That is the trap §14's
bind("<C-c><C-c>", …)comment warns about — a terminal node kills any longer chord grown beneath it — made visible at the point of asking instead of discoverable by reading keymap source.continuationsis the query a which-key popup and a future:describe-bindingsdrill-down both want, which is why it lives onKeymapHandlerather than inline in the:describe-keyhandler.Chord rendering.
Display for KeyChordescapes a literal space as<Space>, the rule already applied to<as<lt>and for a superset of the reason. A bare" "round-tripped throughparse_chord_sequenceand through nothing else: the:line delimits arguments on whitespace, so a captured space submitted an empty argument; and:map/:keymap/ which-key rendered the leader as an invisible column.13.3 K.1.c — per-buffer minor-mode context filter ✅ landed
The dispatch loop passes the active buffer's
MinorModeids intolookup_with_context; only those overlays fire. This is K.1.c: the per-keystroke minor-mode gate that prevents a minor mode active on buffer A from intercepting keystrokes while buffer B is focused.Rule: any context-sensitive keymap query — whether for dispatch (
lookup_with_context) or for introspection (resolve_trace) — must read the active-mode set fromApp::active_modes[active_buffer_id()], not from any document-specific field. Non-Document panes (Multibuffer, Terminal, etc.) have their ownActiveModesentries inApp::active_modesand must not borrow a Document pane's state.And its boundary (DK.4): the rule governs queries that are supposed to be context-sensitive. Not every keymap query is. A minibuffer is a buffer, so while a prompt is open
active_buffer_id()is the*command-line*buffer — correct for "what does this keystroke do right now", wrong for "what shape is this chord sequence", which is a property of the keymap and not of any buffer.any_layer_expects_moretherefore takes no active-mode slice at all. Before applying this rule to a new query, ask whether the answer should change when focus moves to a prompt; if it should not, the query is not context-sensitive and gating it produces §13.2.a's truncation bug.Two bugs found and fixed during the T10-T13 implementation (commit
eb219c5):Wrong buffer id in
build_describe_key_content(crates/lattice-host/src/dispatch.rs). The initial implementation usedself.document_buffer_idto buildactive_modes. Non-Document panes (Multibuffer, Terminal, Help) have a differentactive_buffer_id(); their minor modes were silently excluded, making:describe-keyshow all minor-mode bindings as inactive when focus was elsewhere. Fix: useself.active_buffer_id().MajorMode treated as mode-gated in
resolve_trace(crates/lattice-keymap/src/registry.rs). The initial implementation checkedactive_modes.contains(&id)forKeymapLayer::MajorMode(id). Butlookup_with_contextplaces MajorMode bindings inbuild_always_on_merged— they fire unconditionally, like Builtin. Soresolve_tracemust also markMajorMode(_)asactive = trueregardless ofactive_modes, to match hot-path semantics. Fix:MajorMode(_)joinsBuiltin | User | Bufferin the always-active match arm.
Invariant: the
activeflag on aLayerHitmust faithfully reflect whatlookup_with_contextwould return for the same(mode, chords, active_modes)triple. Divergence between the two paths produces:describe-keyoutput that contradicts actual dispatch behaviour.14. Fall-through bindings — augment-and-continue (SN.3c.2b)
By default a binding replaces its chord: the highest-priority active layer wins (§4), and lower layers are shadowed. That is the right model for most overrides —
active-snippet-mode's<Tab>means next-placeholder while a snippet is live, fully replacinginsert-tab.But some bindings want to augment, not replace: do something, then defer to whatever the chord natively does. The motivating case is
active-snippet-mode's<Esc>— leaving a live snippet should clear the session and exit insert, without the mode hardcoding what<Esc>means (the user may have rebound it). A mode that owns the binding should not have to own the native semantics it sits above.fall_throughis that primitive. It is mode-agnostic and plugin-facing: any mode (today) or WASM plugin (§5.5) can layer behaviour onto a native chord without knowing — or breaking — the binding below it.14.1 Contract
A binding declared
fall_through: trueresolves as: run this binding's action, then re-resolve the same chord against the layers below this one (this binding's owning mode peeled out of the active set) and run the native binding too — recursing if that binding is itselffall_through.- Bounded, unlike vim's
:map. Each hop strictly removes a layer from the active set (monotonically shrinking), so the chain always terminates atBuiltin. It cannot loop the way:map a b/:map b acan. The boolean's "true" therefore means chain-down (compose through lower layers); there is deliberately no:noremap-style "skip intermediates, jump straight to builtin" variant — no caller needs it, and adding it would be a speculativeenum Continuationlift to make only when a concrete second behaviour lands. - Order is mode-effect-first, then native. "Do something, then follow through." For leave: clear the session, then exit insert.
- Mode owns the trigger + augmentation; host owns native resolution. The mode declares the flag on its
KeymapEntryand supplies the handler body; the dispatcher owns the peel-and-re-resolve mechanism. This keeps the mode-ownership split intact — a mode never reaches into "what<Esc>natively does".
14.2 Data model
fall_throughis a property of the binding, not the handler. The handler (keyed byCommandIdin theActionHandlerRegistry) does not know which chord fired it, so it cannot re-resolve "the same chord below me" — the dispatcher can. So the flag rides the binding all the way down, defaultingfalseat every hop:KeymapEntry.fall_through (declared; `keymap_entry!{ …, fall_through: true }`) → KeymapBinding.fall_through (resolve_entries_into_bindings, .with_fall_through) → BoundCommand.fall_through (group_bindings_into_tries, .with_fall_through) → LookupResult::Bound / LayerHit (handed back by lookup_with_context / resolve_trace)Every
from_invocation/KeymapBinding::newcall site stays source-compatible (the field defaultsfalse); opt-in is a.with_fall_through(true)builder or thefall_through:macro form.14.3 Resolution mechanism (the keymap part)
In
dispatch_insert, aBoundresult whosecommand.fall_throughis set and whosecommand.layerisMinorMode(m):- produces the mode action via
action_from_bound; - re-runs
lookup_with_context(Insert, chord, active_modes − m)— the native binding for the same chord with this mode peeled out; - returns the two folded into an
Action::Chain.
resolve_native_actionperforms step 2 and recurses on a furtherfall_through;Unbound/Partialthere →Action::None(the mode action already ran, there is simply nothing native to continue to — it must not fall back to literal-text insertion).chain_actionsflattens nested chains and drops aNonetail, so a binding with no native below it stays a plain single action (no spuriousChainwrapper). The re-resolution is the mechanism — there is no snippet-specific or chord-specific branch anywhere in the dispatcher.14.4 Continuation execution —
Action::Chain// crates/lattice-host/src/action.rs Action::Chain(Vec<Action>), // run each sub-action in orderdispatch_insertresolves a fall-through toChain([mode_action, native_action]). The renderer applies the chain in order:- host
handle_actionloops the sub-actions (Invoke(snippet-leave)thenInvoke(enter-normal), each routing throughrun_invocation); - the TUI
App::applyloopsself.apply(a)so renderer-level sub-actions also route correctly; - the GPUI peer needs no special arm —
Chainfalls through itsdispatch_actionintercept toEditor::dispatch→ the host loop.
Chainis general: any future binding (or the eventual ex-command / macro layers) that needs "run X, then Y" resolves to one.Rejected —
next_actionschannel (option B). Return the modeInvokecarrying the peeled chord + mode, and have the host re-resolve post-dispatch and push the native ontoDispatchOutcome.next_actions. This avoids a newActionvariant but smears re-resolution state (chord, mode-to-peel, keymap) throughCommandInvocation, coupling the invocation to the lookup it should be independent of.Chainkeeps the re-resolution where the keymap context lives (dispatch_insert) and makes the continuation a plain, inspectable action list. (Heuristic #1.)14.5 Introspection (
:describe-key)The flag is visible — augment-and-continue is not hidden host magic (§5.11, design.md).
:describe-key <chord>(which already renders the per-layer trace with[active]/[inactive]from §13.2) gains:- a winner tag —
→ action:snippet-leave (fires now → falls through ↓); - the continuation chain —
↳ then: action:enter-mode-normallines, walked fromresolution.hits(the next active hit below eachfall_throughone); - layer-trace tags —
[active · fall-through]/[inactive · fall-through].
So a user who hits
<Esc>in a snippet and sees both the session clear and insert exit can run:describe-key i_<Esc>and read exactly why, layer by layer.14.6 Paramount-goal alignment
- #2 (extensibility).
fall_throughis a first-class binding primitive any mode/plugin uses to compose onto a native chord without owning its meaning — capability-gated plugin keymaps (§5.5) inherit it for free. - #3 (extensible modal editing). Augment-vs-replace is expressed declaratively in the keymap; the grammar composes through layers rather than each mode re-implementing native behaviour.
- Performance (#1). The extra
lookup_with_contextruns only for afall_throughwinner (rare, keystroke-driven, off any render path). Non-fall-through dispatch is byte-for-byte unchanged — one extra branch on the already-resolvedBoundCommand.
See the slice plan (mode-activation, SN.3c.2b) for sequencing and the landed commit.
15. A motion's binding-modes are derived, not listed (VM.1, VM.4)
A motion is an
nvocommand — vim's word for one that fires in Normal, Visual and operator-pending. That is not a property of any particular chord; it is what being a motion means, and paramount goal #3 says the grammar is the public command API, so it has to hold for a motion a plugin contributed as firmly as forw.Until VM.1 it was a property of four hand-kept lists.
keymap_normal::motion_rowswas walked byregister_normal_bindings, byregister_operator_bindings, bykeymap_visual::register_visual_bindings, and bykeymap_select::register_select_bindings, and its doc comment called itself "single source of truth shared by all three motion surfaces".What that cost
It was false, and silently. These motions reach the Normal binder by other routes in the same file and so appeared in none of the derived surfaces:
Chord Command Dead in ggmotion:goto-first-lineVisual, Select, operator-pending f/F/t/Tmotion:find-char-*Visual, Select <C-d>/<C-u>motion:line-down/-up, count 10Visual, Select, operator-pending <PageDown>/<PageUp>motion:line-down/-upVisual, Select, operator-pending A plugin motion had no route into any of the four lists at all.
bind_mode_keymapbinds the onebinding-modea mode declared and stops, so org's[[moved the cursor in Normal and did nothing in Visual — and the only fix available to the plugin was to declare every motion chord three times.Worth being precise about what was NOT broken: Visual's behaviour.
Editor::write_through_caretrebuilds the selection fromvisual_anchor+cursorat the end of every dispatch, so any reachable cursor-mover extends the selection with no per-motion wiring. The chord simply resolved to nothing.The derivation
keymap_normal::expand_grammar_rows(handle, commands, builtins, layer)walks one layer's finished Normal trie and, for each terminal binding, reads the command's kind out of theCommandRegistry:Motion— keeps the Normal row, and adds a Visual row, a Select row, and<op-prefix><chord>for every composable operator.TextObject— replaces the Normal row (a bare text object in Normal is not a command a user can mean) with Visual + Select rows and the same operator expansion.- anything else — untouched.
That was VM.1. VM.4 moved the Visual and Select rows for a motion out of this pass and into the keymap, and dropped Select rows that can be typed; see Two mechanisms below.
Visual carries the binding verbatim, the same
CommandInvocationthe Normal row holds, so<C-d>'s bakedCount(10)survives into Visual.Two properties make it safe to run over a layer somebody else populated:
- Bind-if-absent. A derived row never overwrites one written deliberately. Visual's
x→ delete ands→ change aliases, the find-char paths'Args::Charcapture routing under an operator, and a mode's own Visual override are all explicit statements, and a default that clobbers them is worse than no default. - Idempotent. Re-running adds nothing, so a plugin reload cannot accumulate rows and boot order stops being load-bearing.
Two mechanisms, split by what each one needs
VM.1 put the whole derivation in one host pass. VM.4 split it, because the two halves need different things and only one of them belongs in the host:
Half Needs Lives in Visual rows for a motion, and Select rows when its first chord can't be typed the command's kind, and the chord's shape lattice-keymap, at the write seam<op-prefix><chord>rows for a motion or text objectthe operator vocabulary ( Builtins)the host's expand_grammar_rows"A motion is live in Visual" is a property of what a motion is, and the keymap can ask a command registry that question. "A motion composes with every operator" is a property of which operators exist, and that vocabulary is host-resolved and lives downstream of this crate. Dragging it two crates down would buy nothing.
Why the pass alone was not a guarantee (VM.4)
As a pass, the Visual half ran at two points: once at boot, and on
PluginLoaded. Anything that wrote a binding outside those two moments escaped:- A re-pushed mode layer.
push_layerfor aMajorMode/MinorModereplaces that layer's tries wholesale (K.1.b), which dropped the derived Visual rows until something re-ran the pass. init.rsand pluginregister-binding. Both bind throughtry_bind_chord_stringat arbitrary times, and neither re-ran the pass.- A binder added to boot after the pass. It was silently missed, and nothing failed.
The drift test enumerated
KeymapLayer::Builtinonly, so it could catch none of these.The mirror
KeymapHandle::set_command_registrygives the keymap the liveCommandRegistryHandle. From then on, every write into a layer's per-mode tries reconciles that path's Visual row, and its Select row when the path's first chord can't be typed (see below). There are exactly four such writes —bind_bound,bind_modes,push_layer,unbind— and every other binding API funnels into one of them:bindandtry_bindgo throughbind_bound, andtry_bind_chord_stringgoes throughtry_bind. So plugin modes, pluginregister-bindingand the user'sinit.rsare all covered by construction, not by remembering to re-run anything.The mirror writes into the layer's own Visual and Select tries, not into the merged trie. That is what keeps
:describe-key,:keymap, which-key and the reverse cache truthful. Lookup-time derivation was rejected on exactly that point.Identity is how the mirror knows its own rows. In a layer's Visual or Select trie, a binding shares the Normal row's
Arcat the same path if and only if it is the mirror. Every write path that would otherwise share anArc—bind_modesnaming Normal and Visual together, a caller-built trie handed topush_layer, an explicit Visual write that happens to reuse the Normal allocation — gives the explicit copy its own allocation. With that invariant held, reconciliation has three rules:- A mirror follows its source. If the slot holds the old Normal binding by identity, it is replaced when the new binding is a motion and removed otherwise. Bind-if-absent alone would leave a stale mirror pointing at a motion the Normal row no longer binds.
- A deliberate binding is never touched. Visual's
x/saliases and a mode's own Visual override are statements someone made on purpose. - An empty slot is filled when the new binding is a motion. For Select, the path's first chord must also not overtype.
Select only takes what can't be typed. In Select mode a printable key replaces the selection (select-mode.md §1, §4). But
dispatch_selectconsults the trie first, and a bound printable, or the prefix of a binding, takes the key instead. So a motion is mirrored into Select only when its first chord wouldn't overtype: arrows, Home/End, PageUp/PageDown,<C-d>/<C-u>.w,f{char},%,;and[[are Visual-only. There is one definition of "would overtype",lattice_keymap::overtypes_in_select, and the dispatcher's fallback and the mirror both call it, so the Select table and Select typing can't disagree again.They did disagree before VM.4. Select's table bound every printable in
motion_rowsfrom the start, and VM.1–VM.3c extended that tof/t/g/%/;/,and to every plugin motion. So text typed over a snippet placeholder was taken whenever it started with one of about 30 characters. Two tests hid it: one ran against an empty table, and one asserted the printables were bound.Setting the registry rescans every existing layer, so the order in which boot sets it relative to the first bind is not load-bearing. The property the mechanism guarantees must not depend on one line staying above another.
The tradeoff, stated rather than hidden. A user cannot permanently remove a motion from Visual by unbinding it there. The next write to that Normal path, a re-push of the layer, or a registry rescan puts the mirror back. That follows from "a motion is live in Visual" being a property of the motion. Shadowing it takes a Visual binding to something else, which rule 2 then protects.
Where each half runs
- The mirror: at every keymap write, once
set_command_registryhas been called. Boot calls it ineditor_boot.rsimmediately after creating the handle. - The operator half:
editor_boot.rs, after every builtin binder and aftertranslate_mode_keymaps, overKeymapLayer::Builtinand each registered mode's layer; and thePluginLoadedsubscriber, over every mode layer. It still fills gaps and is still idempotent. It no longer writes Visual or Select rows for motions, because the keymap already has. For text objects, whose Normal row it replaces (host policy, not a property of the kind), it writes the Visual row only. A text-object path starts with a printable, so a Select row would take the key meant to overtype the selection.
The drift test in
a_motion_is_live_in_visual.rsnow walksKeymapHandle::layers()— every layer, not justBuiltin— so the next hole of the re-push kind fails CI instead of shipping.What it does not cover
A cursor-moving command registered as
CommandKind::Actionis not a motion as far as either mechanism is concerned — that is a statement about the command, not about the keymap.<C-f>/<C-b>,H/M/L,n/N,*/#,`x/'xandgj/gk/g0/g$are typed as actions today, and several of them are genuine motions in vim (dn,dHwork there and are unbound here).%(VM.3b) and;/,(VM.3c) were re-typed as motions. The rest is tracked in the visual-motions slice plan, VM.3.See also
- slice plan: visual-motions -- VM.1--VM.3 sequencing.
- slice plan: lattice-keymap crate + layer-trace -- T1-T13 sequencing.
- slice plan: keymap-substrate -- K.2 sequencing.
- slice plan: help-prefix -- K.3 sequencing.
- slice plan: mode-ownership-cleanup -- MO.1--MO.4, gated on K.2 landing.
- design.md §5.2.3 -- canonical spec.
- design.md §5.2.4 -- extensibility (matches §5.5 here).
- design.md §9 -- plugin WIT interfaces.
crates/lattice-ui-tui/src/keymap.rs-- the catalog (to be promoted to source-of-truth in slice 8.c).crates/lattice-ui-tui/src/input.rs-- the legacy hand-rolled dispatcher (to be replaced incrementally through slices 8.d-g).docs/../archive/m3-binding-census.md-- inventory of every existing built-in binding (one-time migration checklist; produced during planning).
- 8.i.4.b -- AfterCtrlX (Insert mode). ✅ landed.
- 8.i.4.a -- Scaffold + 9 simple-prefix migration. ✅ landed. Adds