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).
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-modeActive majors + minors on the current buffer. <C-h> b:describe-bufferBuffer metadata (kind, flags, mode stack, ...). <C-h> a:aproposCross-cutting search across commands / options / events. <C-h> K:keymapKeymap listing for the current state. Capital K because lowercase kis:describe-key.No bare
<C-h>leaf binding — see §12.3 for the rationale.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.- 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, 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.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.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.
See also
- 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