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}