Skip to main content

lattice_host/
keymap_insert.rs

1//! Insert-mode binding registration + drift-test helpers.
2//!
3//! Audit slice 8.f. Third mode migrated off `input::translate`'s
4//! hand-rolled match table. Insert is bigger than Replace / Visual
5//! because two minor-mode overlays ride on top of base Insert
6//! (architecture doc §5.3):
7//!
8//! - **Completion popup** (`App.insert_completion = Some(...)`):
9//!   the popup claims a fixed set of CTRL-bearing chords plus
10//!   `<Tab>` / `<CR>` / `<Esc>` plus a bare-char wildcard
11//!   ("commit-then-insert"); other chords fall through to base
12//!   Insert.
13//! - **Active snippet** (`App.active_snippet = Some(...)`): the
14//!   snippet claims `<Tab>` / `<S-Tab>` / `<Esc>` for
15//!   placeholder navigation; other chords fall through to base
16//!   Insert. Popup wins when both overlays are active (legacy
17//!   `&& !ctx.insert_completion_open` gate).
18//!
19//! ## Layer model
20//!
21//! Each overlay is registered as a [`KeymapLayer::MinorMode`]
22//! layer pushed onto the registry when the overlay activates and
23//! popped when it deactivates. Push order is enforced by
24//! `App::sync_keymap_overlays`: snippet first, popup second, so
25//! popup's `LayerId` is higher and popup wins on overlapping
26//! chords (preserving the legacy "popup precedes snippet"
27//! gating).
28//!
29//! ## Base Insert bindings
30//!
31//! Registered directly into [`KeymapLayer::Builtin`] +
32//! `BindingMode::Insert` by [`register_insert_bindings`]:
33//!
34//! - `<Esc>` -> `Action::EnterMode(Normal)`
35//! - `<BS>` -> [`Action::DeleteCharBackward`]
36//! - `<CR>` -> `Action::Insert("\n")`
37//! - `<Tab>` -> `Action::Insert("\t")`
38//! - `<C-Space>` -> [`Action::CompletionTrigger`]
39//! - `[<C-x>, <C-o>]` -> [`Action::CompletionTrigger`] (omni-completion)
40//!
41//! SN.3c.1 (2026-06-14): `[<C-x>, <C-s>]` (snippet-expand) is no
42//! longer a Builtin binding — it lives on `snippet-mode`'s `keymap()`
43//! (`KeymapLayer::MinorMode("snippet-mode")`). `<C-x>` stays a partial
44//! prefix because that mode's layer (boot-pushed) provides the
45//! `<C-x><C-s>` terminal.
46//!
47//! `<C-x>` itself is a *partial* trie node (no terminal binding;
48//! children only). Lookup at `[<C-x>]` returns
49//! [`LookupResult::Partial`]; [`dispatch_insert`] translates that
50//! into `Action::SetPending(Pending::AfterCtrlX)`. The next
51//! keystroke arrives with `pending = AfterCtrlX` and the
52//! dispatcher reconstructs the two-chord sequence
53//! `[<C-x>, current_chord]` for the lookup.
54//!
55//! ## Literal-text fall-through
56//!
57//! Per the architecture doc §9 / slice 8.f bullet, "type any
58//! printable char that has no binding" stays a dispatcher default
59//! rather than a registered char wildcard. Lookup at an
60//! unmodified `Char(c)` returns [`LookupResult::Unbound`] in base
61//! Insert; the dispatcher's private `literal_text_fallback` returns
62//! `Action::Insert(c.to_string())` (suppressing `CONTROL`-bearing
63//! chars to match legacy semantics). When the popup layer is
64//! pushed, its char-wildcard wins, so literal typing routes
65//! through `CompletionAcceptThenInsert(c)` instead -- the popup
66//! handler in App decides whether to accept the focused candidate
67//! or fall back to plain insertion.
68//!
69//! ## Modifier transparency (drift caveats)
70//!
71//! Legacy `translate_insert` matched on `event.code` alone for
72//! `<Esc>` / `<BS>` / `<CR>` / `<Tab>` (modifiers ignored), and
73//! short-circuited only `CONTROL` on the `Char(c)` arm. The trie
74//! is precise: `(Esc, NONE)` and `(Esc, CONTROL)` are distinct
75//! chords. To bridge, [`dispatch_insert`] normalizes per the table
76//! below -- but see OS.0b just after it before assuming this strip
77//! is unconditional:
78//!
79//! | chord shape                | normalisation                |
80//! |----------------------------|------------------------------|
81//! | `Special(_)` + ALT/SUPER   | strip ALT, SUPER             |
82//! | `Char(_)` without CTRL     | strip ALT, SUPER             |
83//! | `Char(_)` with CTRL        | strip ALT, SUPER             |
84//!
85//! SHIFT is preserved on specials so the snippet layer can
86//! distinguish `<S-Tab>` from `<Tab>`. SHIFT is preserved on
87//! CTRL+letter so `<C-S-c>` stays distinct from `<C-c>`. SHIFT
88//! is preserved on bare letters too (the chord normalisation in
89//! [`KeyChord::from_event`] already strips redundant SHIFT for
90//! bare ASCII letters where case carries the bit).
91//!
92//! **OS.0b (2026-09-06): the strip is a fallback, not a precondition.**
93//! No BUILTIN Insert binding (base or overlay) uses ALT or SUPER, so
94//! this table's normalized form is exactly what every builtin chord
95//! still resolves to. But the `modes` WIT seam makes no such promise to
96//! a plugin or host mode registering its OWN Insert-mode binding
97//! (`binding-mode: insert`), and nothing in registration rejects an
98//! ALT/SUPER-bearing Insert chord -- so a mode or plugin CAN bind one.
99//! Stripping the modifiers before every lookup, unconditionally, used
100//! to mean such a binding registered correctly and then could never
101//! fire: real keypresses were normalized away before reaching it. Every
102//! lookup site now tries the chord AS PRESSED first
103//! ([`lookup_insert_chord`]) and falls back to this table's normalized
104//! form only when the raw lookup finds nothing -- so a deliberately
105//! ALT/SUPER-bearing binding is reachable, and a chord that was never
106//! going to match either way still costs exactly one lookup.
107//!
108//! Three documented drift cases vs. legacy (acceptable per the
109//! drift test's allow-list -- terminals don't emit these in
110//! practice):
111//!
112//! - `<S-Esc>` (SHIFT + Esc): legacy returned `EnterMode(Normal)`;
113//!   new returns `None` (chord `(Esc, SHIFT)` has no entry; SHIFT
114//!   is preserved on specials).
115//! - `<C-Esc>` (CONTROL + Esc): legacy returned
116//!   `EnterMode(Normal)`; new returns `None`.
117//! - `<S-Tab>` as `KeyCode::Tab + SHIFT` (rare; usually arrives
118//!   as `KeyCode::BackTab` instead): legacy returned `Insert("\t")`;
119//!   new returns `SnippetPrevPlaceholder` if the snippet layer
120//!   is pushed, else `None`. `KeyCode::BackTab` (the common path)
121//!   is unaffected -- `KeyChord::from_event` normalises BackTab
122//!   to `(Tab, SHIFT)`, identical handling.
123
124use std::collections::HashMap;
125use std::sync::Arc;
126
127use lattice_grammar::CommandInvocation;
128use lattice_grammar::SourceLocation;
129use lattice_mode::mode::ModeId;
130use lattice_protocol::ids::CommandId;
131
132use crate::action::Action;
133use crate::actions::ActionIds;
134use crate::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
135use crate::keymap::BindingMode;
136use crate::keymap_registry::KeymapHandle;
137use crate::keymap_trie::{BoundCommand, ChordPattern, KeymapLayer, KeymapTrie, LookupResult};
138
139/// K.1.b (2026-05-30): canonical `ModeId` for the
140/// completion-popup minor-mode keymap layer. Used both by
141/// `completion_popup_layer_bindings` (the per-binding
142/// provenance tag at build time) and by
143/// `App::sync_keymap_overlays` (the push site). Centralised
144/// here so the two stay in lockstep — drift would surface as
145/// `:describe-key` showing the wrong mode name.
146pub fn completion_popup_mode_id() -> ModeId {
147    ModeId::new("completion-popup-mode")
148}
149
150/// Register every chord the legacy `input::translate_insert`
151/// recognised into the supplied handle's `Builtin` layer under
152/// `BindingMode::Insert`. Called at App startup.
153///
154/// `<C-x>` is registered implicitly: inserting
155/// `[<C-x>, <C-o>]` at depth 2 makes the depth-1 lookup of
156/// `[<C-x>]` return [`LookupResult::Partial`]. Same for
157/// `[<C-x>, <C-s>]`.
158pub fn register_insert_bindings(handle: &KeymapHandle, actions: &ActionIds) {
159    // The builtin Insert set is registered into every readline surface, not
160    // just Insert: the `:` line, the `/`·`?` line and the generic prompt are
161    // buffer-backed readline buffers that need the same backspace, word-erase,
162    // line-edit and cursor keys.
163    //
164    // They used to GET them by resolving against the Insert table itself,
165    // which is the bug this closes — that also pulled in every globally-active
166    // minor's Insert bindings. auto-pair binds `<BS>` at
167    // `MinorMode(auto-pair-mode)`, so on the `:` line its handler shadowed the
168    // builtin backspace and backspace did nothing, in both renderers, for as
169    // long as the minibuffer has been a buffer. Registering the BUILTIN set
170    // per surface keeps every key that worked and leaves the minor layers
171    // behind, because a minor that binds in Insert binds in Insert only.
172    for mode in [
173        BindingMode::Insert,
174        BindingMode::Command,
175        BindingMode::Search,
176        BindingMode::Prompt,
177    ] {
178        register_readline_bindings(handle, actions, mode);
179    }
180}
181
182/// The builtin Insert/readline chord set, bound into one [`BindingMode`].
183fn register_readline_bindings(handle: &KeymapHandle, actions: &ActionIds, mode: BindingMode) {
184    let layer = KeymapLayer::Builtin;
185
186    handle.bind(
187        layer,
188        mode,
189        &[lit_special(SpecialKey::Esc)],
190        CommandInvocation::of(actions.enter_mode_normal),
191        source(),
192    );
193
194    // YR.5: vim's insert-register. `<C-r>` is free in Insert — Normal's
195    // `<C-r>` is redo and stays that way — so nothing is displaced.
196    //
197    // The two paths share a prefix and do not shadow each other, which is
198    // worth stating rather than trusting: the trie tries an exact child
199    // before the char wildcard, AND a modifier-bearing chord never
200    // matches the wildcard at all. So `<C-r><C-r>` takes the literal path
201    // and `<C-r>a` the wildcard, with no ordering dependency between the
202    // two binds. That is the shadowing class SU.3e spent a slice on.
203    //
204    // These belong on the BASE Insert layer, not the completion-popup
205    // overlay a few functions down — that trie is live only while the
206    // popup is open, so binding there would make `<C-r>` work only while
207    // completing. It was written there first and the tests caught it.
208    handle.bind(
209        layer,
210        mode,
211        &[
212            ChordPattern::Literal(KeyChord::ctrl('r')),
213            ChordPattern::Literal(KeyChord::ctrl('r')),
214        ],
215        CommandInvocation::of(actions.open_yank_picker),
216        source(),
217    );
218    handle.bind(
219        layer,
220        mode,
221        &[
222            ChordPattern::Literal(KeyChord::ctrl('r')),
223            ChordPattern::CharLiteral,
224        ],
225        CommandInvocation::of(actions.insert_register),
226        source(),
227    );
228    handle.bind(
229        layer,
230        mode,
231        &[lit_special(SpecialKey::Backspace)],
232        CommandInvocation::of(actions.delete_char_backward),
233        source(),
234    );
235    handle.bind(
236        layer,
237        mode,
238        &[lit_special(SpecialKey::Enter)],
239        CommandInvocation::of(actions.insert_newline),
240        source(),
241    );
242    handle.bind(
243        layer,
244        mode,
245        &[lit_special(SpecialKey::Tab)],
246        CommandInvocation::of(actions.insert_tab),
247        source(),
248    );
249    handle.bind(
250        layer,
251        mode,
252        // `Special(Space) + CTRL`, NOT `KeyChord::ctrl(' ')`. The two are
253        // different chords, and only this one is what
254        // `parse_chord_sequence("<C-Space>")` produces — which is what every
255        // plugin binding and user keymap goes through. Binding the other form
256        // worked only while the TUI's key decoding was wrong in the matching
257        // way; it is a chord nothing can type now.
258        &[lit(ctrl_space())],
259        CommandInvocation::of(actions.completion_trigger),
260        source(),
261    );
262    // Readline/vim Insert-mode line editing — general across every buffer.
263    // <C-a>/<C-e> line ends, <C-b>/<C-f> char nav, <C-w>/<C-u>/<C-k> deletes,
264    // <C-t>/<C-d> indent/dedent. (<C-a>/<C-e>/<C-k> deliberately take the
265    // readline meaning over vim's rarely-used Insert bindings.)
266    for (ch, id) in [
267        ('a', actions.insert_cursor_line_start),
268        ('e', actions.insert_cursor_line_end),
269        ('b', actions.insert_cursor_char_left),
270        ('f', actions.insert_cursor_char_right),
271        ('w', actions.insert_delete_word_backward),
272        ('u', actions.insert_delete_to_line_start),
273        ('k', actions.insert_kill_to_line_end),
274        ('t', actions.insert_indent_line),
275        ('d', actions.insert_dedent_line),
276    ] {
277        handle.bind(
278            layer,
279            mode,
280            &[lit(KeyChord::ctrl(ch))],
281            CommandInvocation::of(id),
282            source(),
283        );
284    }
285    // Arrow / Home / End cursor navigation in Insert mode — the same
286    // char/line motions as the `<C-b>`/`<C-f>`/`<C-a>`/`<C-e>` readline
287    // chords, on the keys most users reach for first. Vim-faithful (arrows
288    // move the caret in Insert) and general across every Insert buffer, so
289    // the buffer-backed `:` line (MB.1) gets mid-line editing by arrow key
290    // for free.
291    for (key, id) in [
292        (SpecialKey::Left, actions.insert_cursor_char_left),
293        (SpecialKey::Right, actions.insert_cursor_char_right),
294        (SpecialKey::Home, actions.insert_cursor_line_start),
295        (SpecialKey::End, actions.insert_cursor_line_end),
296    ] {
297        handle.bind(
298            layer,
299            mode,
300            &[lit_special(key)],
301            CommandInvocation::of(id),
302            source(),
303        );
304    }
305    // CSM.K1: `<C-x><C-o>` (vim omni-completion) retired.
306    // `<C-Space>` is the sole popup-open trigger; per-source
307    // filter chords live inside `completion-popup-mode` (CSM.K2).
308    // SN.3c.1 (2026-06-14): `<C-x><C-s>` (snippet-expand) moved off
309    // Builtin onto `snippet-mode`'s `keymap()` at
310    // `KeymapLayer::MinorMode("snippet-mode")` — the chord choice now
311    // lives with the mode that owns the behavior
312    // (`feedback_mode_owns_its_surface`). `<C-x>` is no longer a live
313    // Builtin prefix; the merged trie still resolves it as a `Partial`
314    // through the (boot-pushed) snippet-mode layer, so the two-key
315    // chord still absorbs + dispatches via `dispatch_insert`.
316}
317
318/// Build the completion-popup minor-mode layer's binding set.
319/// Wrapped into the registry by `App::push_completion_popup_layer`
320/// when the popup opens; popped when the popup closes.
321///
322/// Returns one trie keyed under `BindingMode::Insert` -- the only
323/// mode the popup is active in. The registry's merge picks up
324/// every entry under that mode whenever the layer is pushed.
325pub fn completion_popup_layer_bindings(actions: &ActionIds) -> HashMap<BindingMode, KeymapTrie> {
326    let mut trie = KeymapTrie::new();
327    // K.1.b: per-binding provenance tag — same ModeId the
328    // push site uses, so `:describe-key` shows the binding's
329    // layer correctly.
330    let layer = KeymapLayer::MinorMode(completion_popup_mode_id());
331
332    bind_invocation(
333        &mut trie,
334        layer,
335        &[lit(KeyChord::ctrl('n'))],
336        actions.completion_next,
337    );
338    bind_invocation(
339        &mut trie,
340        layer,
341        &[lit_special(SpecialKey::Down)],
342        actions.completion_next,
343    );
344    bind_invocation(
345        &mut trie,
346        layer,
347        &[lit(KeyChord::ctrl('p'))],
348        actions.completion_prev,
349    );
350    bind_invocation(
351        &mut trie,
352        layer,
353        &[lit_special(SpecialKey::Up)],
354        actions.completion_prev,
355    );
356    bind_invocation(
357        &mut trie,
358        layer,
359        &[lit(KeyChord::ctrl('y'))],
360        actions.completion_accept,
361    );
362    bind_invocation(
363        &mut trie,
364        layer,
365        &[lit_special(SpecialKey::Tab)],
366        actions.completion_accept,
367    );
368    bind_invocation(
369        &mut trie,
370        layer,
371        &[lit_special(SpecialKey::Enter)],
372        actions.completion_accept,
373    );
374    bind_invocation(
375        &mut trie,
376        layer,
377        &[lit(KeyChord::ctrl('e'))],
378        actions.completion_cancel,
379    );
380    bind_invocation(
381        &mut trie,
382        layer,
383        &[lit_special(SpecialKey::Esc)],
384        actions.completion_cancel_and_exit_insert,
385    );
386    // CSM.K2: inside the popup, `<C-Space>` clears the active
387    // source filter (mirrors vim's "show everything again"
388    // intent). The unfiltered insert-mode trigger lives one
389    // layer down (base insert keymap) and is shadowed while
390    // the popup is open.
391    bind_invocation(
392        &mut trie,
393        layer,
394        &[lit(ctrl_space())],
395        actions.completion_filter_clear,
396    );
397    bind_invocation(
398        &mut trie,
399        layer,
400        &[lit(KeyChord::ctrl('d'))],
401        actions.completion_toggle_docs,
402    );
403    // CSM.K2: docs-scroll moved off `<C-f>`/`<C-b>` (those now
404    // act as filter chords -- path / buffer-words). Docs scroll
405    // is on PageDown / PageUp, which mirrors the page-wise
406    // semantics without colliding with the chord namespace.
407    bind_invocation(
408        &mut trie,
409        layer,
410        &[lit_special(SpecialKey::PageDown)],
411        actions.completion_docs_scroll_down,
412    );
413    bind_invocation(
414        &mut trie,
415        layer,
416        &[lit_special(SpecialKey::PageUp)],
417        actions.completion_docs_scroll_up,
418    );
419    // CSM.K2: single-key filter chords inside the popup. Each
420    // chord targets a specific completion source -- the static
421    // `Args::String(SourceId)` payload is folded into the bound
422    // invocation, so a single action covers every source.
423    use lattice_completion::insert::{
424        BufferWordsSource, LSP_COMPLETION_SOURCE_ID, PATH_SOURCE_ID, SNIPPET_SOURCE_ID,
425        TREE_SITTER_SYMBOL_SOURCE_ID,
426    };
427    bind_invocation_with_string(
428        &mut trie,
429        layer,
430        &[lit(KeyChord::ctrl('b'))],
431        actions.completion_filter_to_source,
432        BufferWordsSource::ID,
433    );
434    bind_invocation_with_string(
435        &mut trie,
436        layer,
437        &[lit(KeyChord::ctrl('o'))],
438        actions.completion_filter_to_source,
439        LSP_COMPLETION_SOURCE_ID,
440    );
441    bind_invocation_with_string(
442        &mut trie,
443        layer,
444        &[lit(KeyChord::ctrl('f'))],
445        actions.completion_filter_to_source,
446        PATH_SOURCE_ID,
447    );
448    bind_invocation_with_string(
449        &mut trie,
450        layer,
451        &[lit(KeyChord::ctrl('t'))],
452        actions.completion_filter_to_source,
453        TREE_SITTER_SYMBOL_SOURCE_ID,
454    );
455    bind_invocation_with_string(
456        &mut trie,
457        layer,
458        &[lit(KeyChord::ctrl('s'))],
459        actions.completion_filter_to_source,
460        SNIPPET_SOURCE_ID,
461    );
462    // Char wildcard: any bare printable -> commit-or-insert. The
463    // dispatcher folds the captured char into the typed
464    // invocation's `Args::Char(c)`; the bound `ActionSpec`
465    // returns `AppEffect::CompletionAcceptThenInsert(c)`.
466    bind_invocation(
467        &mut trie,
468        layer,
469        &[ChordPattern::CharLiteral],
470        actions.completion_accept_then_insert,
471    );
472
473    let mut modes = HashMap::new();
474    modes.insert(BindingMode::Insert, trie);
475    modes
476}
477
478/// Dispatch a key event in Insert mode through the layered
479/// keymap registry. Replaces the legacy
480/// `input::translate_insert` plus the
481/// `translate_insert_completion_popup` and
482/// `translate_active_snippet` overlay branches at the top of
483/// `input::translate`.
484///
485/// 1. `pending == AfterCtrlX`: reconstruct
486///    `[<C-x>, normalised(event)]`, look up. Bound -> the bound
487///    action; anything else -> `SetPending(None)` to drop the
488///    pending state and let the user retry (matches legacy).
489/// 2. Otherwise: normalise the chord per the modifier table in
490///    this module's docstring; look up `[chord]`.
491///    - `Bound` -> the bound action. Wildcard captures fill the
492///      char placeholder in `CompletionAcceptThenInsert`.
493///    - `Partial` -> absorb into `App::partial_chord`, and keep
494///      absorbing while the walk stays partial (OR.7c). Insert
495///      chords are therefore any depth, as in Normal; they used
496///      to be capped at two because a CONTINUING partial fell
497///      through to `Action::None`, which no builtin could hit
498///      (`<C-x><C-o>` is depth two) and a plugin's `<C-c>ni`
499///      could.
500///    - `Unbound` -> private `literal_text_fallback` for printable
501///      chars without CONTROL; otherwise `Action::None`.
502pub fn dispatch_insert(
503    handle: &KeymapHandle,
504    mode: BindingMode,
505    chord: &KeyChord,
506    partial_chord: &[KeyChord],
507    active_minor_modes: &[ModeId],
508) -> Action {
509    // SN.3c.2a (2026-06-14): Insert-mode dispatch is now K.1.c-gated,
510    // mirroring `translate_normal`. Previously this used
511    // `handle.lookup`, which folds in EVERY registered minor-mode
512    // layer unconditionally (`registry.rs`: `lookup` treats all
513    // `minor_mode_tries` keys as active) — so an inactive minor mode's
514    // Insert bindings (e.g. `active-snippet-mode`'s `<Tab>` / `<Esc>`)
515    // shadowed base Insert in every buffer. Routing through
516    // `lookup_with_context` with the active buffer's minor set scopes
517    // those bindings to buffers where the mode is actually active, the
518    // same per-buffer guarantee Normal mode already had.
519    //
520    // Slice 8.i.4: partial-chord dispatch wins when a previous
521    // keystroke absorbed a prefix into `App::partial_chord`.
522    // This drives the `<C-x>` family (`<C-x><C-o>` /
523    // `<C-x><C-s>`) and any future Insert-mode multi-key chord.
524    //
525    // OS.0b: every lookup below goes through `lookup_insert_chord`,
526    // which tries the chord AS PRESSED first and only falls back to the
527    // normalized form when the raw lookup found nothing. See that
528    // function's docs for why raw must go first.
529    if !partial_chord.is_empty() {
530        let lookup = lookup_insert_chord(handle, mode, partial_chord, *chord, active_minor_modes);
531        return match lookup.result {
532            LookupResult::Bound { command, captured } => bound_or_fall_through(
533                handle,
534                partial_chord,
535                *chord,
536                active_minor_modes,
537                &command,
538                &captured,
539            ),
540            // OR.7c: a chord that CONTINUES the prefix absorbs, exactly as the
541            // first one did.
542            //
543            // This arm used to fall into the `_ => Action::None` below, which
544            // silently capped Insert-mode chords at depth TWO: the first key
545            // absorbed, and the second had to be terminal or the walk died and
546            // the partial was dropped. `<C-x><C-o>` is depth two, so nothing
547            // in the builtin catalog ever noticed — this module's own doc said
548            // as much ("no caller can produce one with the current catalog").
549            //
550            // A plugin can. `org-global-mode` binds `<C-c>ni`, and it resolved
551            // `Bound` in the trie while being unreachable by typing, which is
552            // the worst shape a keymap bug takes: `:describe-key` agrees with
553            // you and the key does nothing.
554            //
555            // Normal mode has always absorbed continuations this way; this
556            // makes Insert agree rather than teaching plugins a depth limit
557            // that exists for no reason.
558            LookupResult::Partial => Action::AbsorbPartialChord(lookup.resolved),
559            // An unbound continuation drops the prefix and does nothing —
560            // deliberately NOT `literal_text_fallback`. Typing `<C-c>nx` must
561            // not leave an `x` in the buffer: the user was reaching for a
562            // chord, and a stray character is worse than silence.
563            LookupResult::Unbound => Action::None,
564        };
565    }
566
567    let lookup = lookup_insert_chord(handle, mode, &[], *chord, active_minor_modes);
568    match lookup.result {
569        LookupResult::Bound { command, captured } => {
570            bound_or_fall_through(handle, &[], *chord, active_minor_modes, &command, &captured)
571        }
572        LookupResult::Partial => {
573            // Slice 8.i.4.b: every trie `Partial` in Insert mode
574            // (currently only `<C-x>`) absorbs into
575            // `App::partial_chord` via `AbsorbPartialChord`. The
576            // next keystroke runs with this stack as prefix and
577            // hits the trie's resolved `[<C-x>, <C-o>]` /
578            // `[<C-x>, <C-s>]` binding. `lookup.resolved` is whichever
579            // form (raw or normalized) actually matched the `Partial`
580            // node, so the next keystroke's prefix is the one the trie
581            // will recognize.
582            Action::AbsorbPartialChord(lookup.resolved)
583        }
584        LookupResult::Unbound => literal_text_fallback(chord),
585    }
586}
587
588/// OS.0b: the outcome of [`lookup_insert_chord`] — the `LookupResult`
589/// plus which form of the incoming chord (as pressed, or with
590/// ALT/SUPER stripped) actually produced it. A caller that continues a
591/// multi-key sequence or re-resolves a fall-through continuation must
592/// follow up against the SAME form; silently switching to the other one
593/// would look up a chord the trie was never asked about.
594pub(crate) struct InsertLookup {
595    pub(crate) result: LookupResult,
596    pub(crate) resolved: KeyChord,
597}
598
599/// OS.0b: look a chord up **as it arrived** first, so a mode or plugin
600/// layer that deliberately binds an ALT/SUPER-bearing chord is
601/// reachable — the `modes` WIT seam makes no promise against it, unlike
602/// the BUILTIN catalog this module's normalize table was designed for
603/// (see the module docstring). Fall back to the normalized form only
604/// when the raw lookup found nothing AND normalizing would actually
605/// change the chord, so a chord carrying neither ALT nor SUPER costs
606/// exactly one lookup, as it always did.
607///
608/// `Partial` counts as a raw hit: an ALT/SUPER-bearing PREFIX is a
609/// deliberate registration, and falling back mid-sequence would strand
610/// its continuation.
611///
612/// Shared by `dispatch_insert`'s three lookup sites (the partial-chord
613/// branch, the fresh-chord branch, and `resolve_native_action`'s
614/// fall-through re-resolve) and by `keymap_select::minor_select_action`
615/// (SN.3d.4), which keys its minor bindings the same way and needs the
616/// same raw-first rule.
617pub(crate) fn lookup_insert_chord(
618    handle: &KeymapHandle,
619    mode: BindingMode,
620    prefix: &[KeyChord],
621    chord: KeyChord,
622    active_minor_modes: &[ModeId],
623) -> InsertLookup {
624    let mut raw_path: Vec<KeyChord> = prefix.to_vec();
625    raw_path.push(chord);
626    let raw = handle.lookup_with_context(mode, &raw_path, active_minor_modes);
627    if matches!(raw, LookupResult::Bound { .. } | LookupResult::Partial) {
628        return InsertLookup {
629            result: raw,
630            resolved: chord,
631        };
632    }
633    let normalized = normalize_for_insert_lookup(chord);
634    if normalized == chord {
635        // Nothing to fall back to -- the raw result (Unbound, since the
636        // Bound/Partial case returned above) IS the answer.
637        return InsertLookup {
638            result: raw,
639            resolved: chord,
640        };
641    }
642    let mut normalized_path: Vec<KeyChord> = prefix.to_vec();
643    normalized_path.push(normalized);
644    InsertLookup {
645        result: handle.lookup_with_context(mode, &normalized_path, active_minor_modes),
646        resolved: normalized,
647    }
648}
649
650/// SN.3c.2b: resolve a `Bound` result into an `Action`, honoring
651/// `fall_through`. When the bound binding is `fall_through` and lives on
652/// a `MinorMode(m)` layer, run its action AND THEN re-resolve the same
653/// chord with `m` peeled out of the active set, chaining the native
654/// binding's action after it. Bounded: each hop removes a layer, so the
655/// recursion terminates at `Builtin` — it cannot loop the way vim's
656/// `:map` can.
657///
658/// OS.0b: takes the ORIGINAL incoming `chord` (not a pre-resolved path)
659/// so the fall-through re-resolve can independently try raw-then-
660/// normalized against the peeled active set — the layer that bound the
661/// ALT-bearing chord may be gone, but a lower layer's NORMALIZED
662/// binding (e.g. Builtin's plain `<CR>`) should still be reachable.
663fn bound_or_fall_through(
664    handle: &KeymapHandle,
665    prefix: &[KeyChord],
666    chord: KeyChord,
667    active_minor_modes: &[ModeId],
668    command: &Arc<BoundCommand>,
669    captured: &[char],
670) -> Action {
671    let action = action_from_bound(command, captured);
672    if !command.fall_through {
673        return action;
674    }
675    // Peel the binding's own mode out of the active set and re-resolve
676    // the same chord against the layers below — the native binding.
677    let peeled: Vec<ModeId> = match command.layer {
678        KeymapLayer::MinorMode(m) => active_minor_modes
679            .iter()
680            .copied()
681            .filter(|x| *x != m)
682            .collect(),
683        // A fall_through binding on a non-minor layer has nothing above
684        // it to peel; treat as no continuation (defensive — entries set
685        // fall_through only on mode layers).
686        _ => return action,
687    };
688    chain_actions(
689        action,
690        resolve_native_action(handle, prefix, chord, &peeled),
691    )
692}
693
694/// SN.3c.2b: re-resolve a chord for a fall-through continuation,
695/// returning the native binding's `Action` (recursing if that binding
696/// is itself `fall_through`). `Unbound` / `Partial` → `Action::None`:
697/// the mode action already ran; there is simply nothing native to
698/// continue to (so we must NOT fall back to literal-text insertion
699/// here, which would type the chord's character).
700fn resolve_native_action(
701    handle: &KeymapHandle,
702    prefix: &[KeyChord],
703    chord: KeyChord,
704    active_minor_modes: &[ModeId],
705) -> Action {
706    let lookup = lookup_insert_chord(
707        handle,
708        BindingMode::Insert,
709        prefix,
710        chord,
711        active_minor_modes,
712    );
713    match lookup.result {
714        LookupResult::Bound { command, captured } => bound_or_fall_through(
715            handle,
716            prefix,
717            chord,
718            active_minor_modes,
719            &command,
720            &captured,
721        ),
722        _ => Action::None,
723    }
724}
725
726/// SN.3c.2b: sequence two actions, flattening nested chains and
727/// dropping a `None` continuation so a single-action result stays a
728/// plain `Action` (no `Chain` wrapper unless there is genuinely a
729/// chain).
730///
731/// SN.3d.4: `pub(crate)` so Select-mode fall-through
732/// (`keymap_select::minor_select_action`) reuses the same chaining
733/// primitive — a `fall_through` minor binding sequences its mode
734/// action with the native continuation identically in both modes.
735pub(crate) fn chain_actions(first: Action, rest: Action) -> Action {
736    match rest {
737        Action::None => first,
738        Action::Chain(mut v) => {
739            let mut out = Vec::with_capacity(v.len() + 1);
740            out.push(first);
741            out.append(&mut v);
742            Action::Chain(out)
743        }
744        other => Action::Chain(vec![first, other]),
745    }
746}
747
748/// Mode-specific modifier strip. See module docstring's table.
749///
750/// SN.3d.4: `pub(crate)` so the Select-mode minor-binding lookup
751/// (`keymap_select::minor_select_action`) normalizes chords the SAME
752/// way — minor-mode bindings (e.g. the snippet `<Tab>` / `<S-Tab>` /
753/// `<Esc>`) are keyed identically regardless of the host modal, so
754/// `<S-Tab>` must keep SHIFT in Select too (the base-Select normalize
755/// strips it, which would collapse `<S-Tab>` into `<Tab>`).
756pub(crate) fn normalize_for_insert_lookup(chord: KeyChord) -> KeyChord {
757    // Strip ALT and SUPER on every chord -- no Insert binding
758    // (base or overlay) uses them. Keep CTRL and SHIFT to
759    // distinguish `<C-y>` from `y` and `<S-Tab>` from `<Tab>`.
760    let mut mods = KeyMods::NONE;
761    if chord.mods.ctrl() {
762        mods = mods | KeyMods::CTRL;
763    }
764    if chord.mods.shift() {
765        mods = mods | KeyMods::SHIFT;
766    }
767    KeyChord {
768        key: chord.key,
769        mods,
770    }
771}
772
773/// Pull the typed `CommandInvocation` out of a bound trie node,
774/// folding any captured wildcard char into the invocation's
775/// `Args::Char(c)` (slice 8.i.4.e: replaces the prior
776/// `legacy_action`-aware substitution with the same shape used
777/// in keymap_normal / keymap_replace -- the bound `ActionSpec`
778/// validates and emits the typed `AppEffect`).
779fn action_from_bound(bound: &Arc<BoundCommand>, captured: &[char]) -> Action {
780    let mut inv = bound.command.clone();
781    if let Some(&c) = captured.first() {
782        inv = inv.with_args(lattice_grammar::args::Args::Char(c));
783    }
784    Action::Invoke(inv)
785}
786
787/// Dispatcher fallback for unbound chords in base Insert. Mirrors
788/// the legacy `translate_insert`'s tail:
789/// - CONTROL-bearing -> `Action::None`.
790/// - `KeyCode::Char(c)` (any non-CONTROL modifier) -> `Insert(c.to_string())`.
791/// - Anything else -> `Action::None`.
792fn literal_text_fallback(chord: &KeyChord) -> Action {
793    if chord.mods.ctrl() {
794        return Action::None;
795    }
796    match chord.key {
797        KeyKind::Char(c) => Action::Insert(c.to_string()),
798        // A modified space that no binding claimed still types a space.
799        //
800        // Without this arm, promoting a modified space to `Special(Space)`
801        // would make Shift+Space stop inserting anything — it would reach here
802        // as a `Special` and fall out as `Action::None`. Narrow (a Unix
803        // terminal reports Shift+Space with no modifier at all; the Windows
804        // console does not), but "a key that used to type a space now does
805        // nothing" is a worse bug than the one being fixed, and GPUI has been
806        // silently doing exactly that.
807        KeyKind::Special(SpecialKey::Space) => Action::Insert(" ".to_string()),
808        _ => Action::None,
809    }
810}
811
812/// The `<C-Space>` chord as the PARSER spells it.
813///
814/// `KeyChord::ctrl(' ')` is `Char(' ') + CTRL` and is a different chord — one
815/// no real keypress produces. Anything binding `<C-Space>` must agree with
816/// `parse_chord_sequence`, which is the path every plugin and user binding
817/// takes.
818fn ctrl_space() -> KeyChord {
819    KeyChord::new(KeyKind::Special(SpecialKey::Space), KeyMods::CTRL)
820}
821
822fn lit(chord: KeyChord) -> ChordPattern {
823    ChordPattern::Literal(chord)
824}
825
826fn lit_special(s: SpecialKey) -> ChordPattern {
827    ChordPattern::Literal(KeyChord::special(s))
828}
829
830fn source() -> SourceLocation {
831    SourceLocation::builtin_file(file!(), line!())
832}
833
834/// Helper for the per-overlay trie builders -- stages a typed
835/// `CommandInvocation` (slice 8.i.4.e: replaces the legacy
836/// `bind_action` that wrapped `Action::Foo` payloads via
837/// `BoundCommand::from_legacy_action`). `KeymapLayer` is set on
838/// the `BoundCommand` for `:describe-key` provenance; the
839/// registry overrides the layer tag with the freshly-issued
840/// `MinorMode(id)` when the layer is pushed.
841fn bind_invocation(
842    trie: &mut KeymapTrie,
843    layer: KeymapLayer,
844    path: &[ChordPattern],
845    command: CommandId,
846) {
847    let bound = Arc::new(BoundCommand::from_invocation(
848        CommandInvocation::of(command),
849        source(),
850        layer,
851    ));
852    trie.insert(path, bound);
853}
854
855/// CSM.K2: like `bind_invocation` but folds a constant
856/// `Args::String(...)` payload into the bound invocation.
857/// Used by the popup-mode filter chords (`<C-b>` ->
858/// `completion-filter-to-source("gen:buffer-words")`, etc.)
859/// so the `captured_string_action` helper can dispatch to the
860/// right `AppEffect` without a separate action per source.
861fn bind_invocation_with_string(
862    trie: &mut KeymapTrie,
863    layer: KeymapLayer,
864    path: &[ChordPattern],
865    command: CommandId,
866    payload: &str,
867) {
868    let inv = CommandInvocation::of(command)
869        .with_args(lattice_grammar::Args::String(payload.to_string()));
870    let bound = Arc::new(BoundCommand::from_invocation(inv, source(), layer));
871    trie.insert(path, bound);
872}
873
874/// OS.0b regression tests: `dispatch_insert`'s raw-then-fallback fix
875/// must not change any of the behaviour that worked before it. Each
876/// test below pins one fact the fix is not allowed to break; see
877/// `.superpowers/sdd/org-structure-editing/os0b-brief.md` for why these
878/// four were chosen. `crates/lattice-host/tests/plugin_insert_mode_chords.rs`
879/// covers the fix's actual acceptance criterion (an ALT-bearing plugin
880/// binding reaching apply-action end to end); these are host-local unit
881/// tests of the dispatch function itself.
882#[cfg(test)]
883mod os0b_tests {
884    #![allow(clippy::unwrap_used, clippy::panic)]
885    use super::*;
886
887    fn shared_actions() -> &'static ActionIds {
888        use std::sync::OnceLock;
889        static A: OnceLock<ActionIds> = OnceLock::new();
890        A.get_or_init(|| {
891            let mut r = lattice_grammar::CommandRegistry::new();
892            let b = lattice_grammar::builtins::populate(&mut r);
893            let _ = lattice_grammar::ex_commands::populate(&mut r);
894            crate::actions::populate(&mut r, &b)
895        })
896    }
897
898    /// A handle carrying only the Builtin Insert catalog — no minor
899    /// modes. Enough to test the raw-then-fallback strip against the
900    /// bindings that motivated it in the first place.
901    fn builtin_handle() -> KeymapHandle {
902        let h = KeymapHandle::new();
903        register_insert_bindings(&h, shared_actions());
904        h
905    }
906
907    fn alt(key: KeyKind) -> KeyChord {
908        KeyChord::new(key, KeyMods::ALT)
909    }
910
911    fn invoked_command(action: &Action) -> CommandId {
912        match action {
913            Action::Invoke(inv) => inv.command,
914            other => panic!("expected Action::Invoke, got {other:?}"),
915        }
916    }
917
918    /// `<M-CR>` unbound anywhere (no mode claims it) must still fall
919    /// back to the normalized `<CR>` and reach the Builtin newline —
920    /// exactly what worked before OS.0b, now reached via the fallback
921    /// arm of `lookup_insert_chord` rather than unconditional stripping.
922    #[test]
923    fn alt_enter_still_reaches_the_builtin_newline_when_nothing_binds_it() {
924        let h = builtin_handle();
925        let chord = alt(KeyKind::Special(SpecialKey::Enter));
926        let action = dispatch_insert(&h, lattice_keymap::BindingMode::Insert, &chord, &[], &[]);
927        assert_eq!(
928            invoked_command(&action),
929            shared_actions().insert_newline,
930            "<M-CR> with nothing bound to it must still fall back to <CR>'s builtin newline"
931        );
932    }
933
934    /// `<M-x>` is unbound both raw and normalized, so it must still hit
935    /// the literal-text fallback and type a plain `x` — the raw lookup
936    /// trying first must not swallow the printable-fallback path.
937    #[test]
938    fn alt_x_still_types_a_literal_x() {
939        let h = builtin_handle();
940        let chord = alt(KeyKind::Char('x'));
941        match dispatch_insert(&h, lattice_keymap::BindingMode::Insert, &chord, &[], &[]) {
942            Action::Insert(s) => assert_eq!(s, "x"),
943            other => panic!("expected a literal 'x' insert, got {other:?}"),
944        }
945    }
946
947    /// SHIFT was never stripped by `normalize_for_insert_lookup`, so
948    /// `<S-Tab>` and `<Tab>` must keep resolving to DIFFERENT bindings
949    /// via the raw lookup's first try — the fix must not collapse them
950    /// the way stripping ALT/SUPER-only would be a no-op here.
951    #[test]
952    fn shift_tab_is_unaffected() {
953        let h = builtin_handle();
954        let mode_id = ModeId::new("os0b-shift-tab-mode");
955        let shift_tab_command = CommandId::new(u64::MAX - 1);
956        h.bind(
957            KeymapLayer::MinorMode(mode_id),
958            BindingMode::Insert,
959            &[lit(KeyChord::new(
960                KeyKind::Special(SpecialKey::Tab),
961                KeyMods::SHIFT,
962            ))],
963            CommandInvocation::of(shift_tab_command),
964            source(),
965        );
966
967        let shift_tab = KeyChord::new(KeyKind::Special(SpecialKey::Tab), KeyMods::SHIFT);
968        let action = dispatch_insert(
969            &h,
970            lattice_keymap::BindingMode::Insert,
971            &shift_tab,
972            &[],
973            &[mode_id],
974        );
975        assert_eq!(
976            invoked_command(&action),
977            shift_tab_command,
978            "<S-Tab> must resolve to the minor binding on its own, not fall through to <Tab>"
979        );
980
981        let plain_tab = KeyChord::special(SpecialKey::Tab);
982        let action = dispatch_insert(
983            &h,
984            lattice_keymap::BindingMode::Insert,
985            &plain_tab,
986            &[],
987            &[mode_id],
988        );
989        assert_eq!(
990            invoked_command(&action),
991            shared_actions().insert_tab,
992            "plain <Tab> must stay on the Builtin binding, unaffected by the <S-Tab> minor entry"
993        );
994    }
995
996    /// The partial-chord branch (a previously-absorbed `<C-x>` prefix)
997    /// must get the SAME raw-then-fallback treatment as the fresh-chord
998    /// branch. Before OS.0b, the second chord of a multi-key sequence
999    /// was normalized (ALT stripped) before ever being appended to the
1000    /// lookup path, so an ALT-bearing two-chord binding died at its own
1001    /// prefix even though it registered correctly.
1002    #[test]
1003    fn the_ctrl_x_ctrl_o_two_chord_still_resolves() {
1004        let h = builtin_handle();
1005        let mode_id = ModeId::new("os0b-two-chord-mode");
1006        let two_chord_command = CommandId::new(u64::MAX - 2);
1007        h.bind(
1008            KeymapLayer::MinorMode(mode_id),
1009            BindingMode::Insert,
1010            &[lit(KeyChord::ctrl('x')), lit(alt(KeyKind::Char('o')))],
1011            CommandInvocation::of(two_chord_command),
1012            source(),
1013        );
1014
1015        // First key: <C-x> absorbs as a partial prefix. It carries no
1016        // ALT/SUPER, so raw == normalized here and this costs one
1017        // lookup, same as before OS.0b.
1018        let ctrl_x = KeyChord::ctrl('x');
1019        let absorbed = match dispatch_insert(
1020            &h,
1021            lattice_keymap::BindingMode::Insert,
1022            &ctrl_x,
1023            &[],
1024            &[mode_id],
1025        ) {
1026            Action::AbsorbPartialChord(c) => c,
1027            other => panic!("expected <C-x> to absorb as a partial prefix, got {other:?}"),
1028        };
1029
1030        // Second key: <M-o> completes the sequence via the
1031        // partial-chord branch.
1032        let alt_o = alt(KeyKind::Char('o'));
1033        let action = dispatch_insert(
1034            &h,
1035            lattice_keymap::BindingMode::Insert,
1036            &alt_o,
1037            &[absorbed],
1038            &[mode_id],
1039        );
1040        assert_eq!(
1041            invoked_command(&action),
1042            two_chord_command,
1043            "the two-chord <C-x><M-o> binding must resolve through the partial-chord branch"
1044        );
1045    }
1046}