Skip to main content

lattice_host/
actions.rs

1//! App-side action registrations -- the `CommandKind::Action`
2//! peers of the grammar's built-in motions / operators / text-
3//! objects (`lattice_grammar::builtins`) and built-in ex-commands
4//! (`lattice_grammar::ex_commands`).
5//!
6//! See `docs/dev/notes/8i-approach.md` for the slice 8.i plan. Each action
7//! registered here returns `Effect::AppAction(AppEffect::Foo)`
8//! from its `apply` closure; the App's `apply_app_effect` then
9//! routes the `AppEffect` to the historical handler. Once slice
10//! 8.i.4 retires the legacy `Action` enum, the bodies move
11//! directly into `apply_app_effect` and this layer becomes the
12//! sole producer.
13//!
14//! New AppEffect variants land here as a single line per variant
15//! plus a one-line ID field on [`ActionIds`]; the actual
16//! per-mode chord bindings live in `keymap_normal.rs` (and
17//! sibling per-mode modules) and consume [`ActionIds`] alongside
18//! `Builtins`.
19
20use lattice_grammar::AppEffect;
21use lattice_grammar::CommandRegistry;
22use lattice_grammar::HScroll;
23use lattice_grammar::ModalState;
24use lattice_grammar::PaneDirection;
25use lattice_grammar::Register;
26use lattice_grammar::ScrollPos;
27use lattice_grammar::SearchDirection;
28use lattice_grammar::ViewportPos;
29use lattice_grammar::VisualKind;
30use lattice_grammar::args::Args;
31use lattice_grammar::builtins::Builtins;
32use lattice_grammar::effect::Effect;
33use lattice_grammar::registry::ActionSpec;
34use lattice_grammar::registry::OperatorId;
35use lattice_protocol::ids::CommandId;
36use std::sync::Arc;
37
38/// Strongly-typed handles to every App-side action registered
39/// in the global [`CommandRegistry`]. Mirrors the shape of
40/// `lattice_grammar::builtins::Builtins`: each field is the
41/// `CommandId` produced by [`CommandRegistry::register_action`]
42/// at startup. The App stores this struct; per-mode keymap
43/// modules consume it to build typed `CommandInvocation`s for
44/// chord bindings.
45#[derive(Debug, Clone, Copy, Default)]
46pub struct ActionIds {
47    pub match_bracket: CommandId,
48    pub toggle_case_at_cursor: CommandId,
49    pub open_line_below: CommandId,
50    pub open_line_above: CommandId,
51    pub lsp_hover_request: CommandId,
52    pub search_next: CommandId,
53    pub search_previous: CommandId,
54    /// CM.2 (2026-07-22): Builtin `]qq` — next error entry.
55    pub error_next: CommandId,
56    /// CM.2 (2026-07-22): Builtin `[qq` — previous error entry.
57    pub error_prev: CommandId,
58    /// CM.7 (2026-07-22): Builtin `[Q` — first error entry.
59    pub error_first: CommandId,
60    /// CM.7 (2026-07-22): Builtin `]Q` — last error entry.
61    pub error_last: CommandId,
62    /// CM.7 (2026-07-22): Builtin `]qf` — first entry of the next file.
63    pub error_next_file: CommandId,
64    /// CM.7 (2026-07-22): Builtin `[qf` — first entry of the previous file.
65    pub error_prev_file: CommandId,
66    pub jump_history_back: CommandId,
67    /// PBH.3: `<C-6>` / `<C-7>` -- walk the active pane's buffer trail.
68    pub pane_history_back: CommandId,
69    pub pane_history_forward: CommandId,
70    pub jump_history_forward: CommandId,
71    pub walk_mark_history_back: CommandId,
72    pub walk_mark_history_forward: CommandId,
73    pub tag_stack_pop: CommandId,
74    pub open_fold_at_cursor: CommandId,
75    pub close_fold_at_cursor: CommandId,
76    pub toggle_fold_at_cursor: CommandId,
77    pub open_all_folds: CommandId,
78    pub close_all_folds: CommandId,
79    pub cycle_fold_at_cursor: CommandId,
80    pub cycle_folds_global: CommandId,
81    pub goto_parent_fold: CommandId,
82    pub delete_fold_at_cursor: CommandId,
83    pub open_folds_recursively: CommandId,
84    pub close_folds_recursively: CommandId,
85    pub delete_folds_recursively: CommandId,
86    pub goto_next_fold: CommandId,
87    pub goto_prev_fold: CommandId,
88    pub toggle_fold_enable: CommandId,
89    pub undo: CommandId,
90    pub redo: CommandId,
91    pub repeat_last_change: CommandId,
92    pub page_down: CommandId,
93    /// VM.3j-2: vim's `<C-d>` / `<C-u>` — SCROLL commands, not motions.
94    pub half_page_down: CommandId,
95    pub half_page_up: CommandId,
96    pub page_up: CommandId,
97    pub scroll_line_up: CommandId,
98    pub scroll_line_down: CommandId,
99    pub redraw_screen: CommandId,
100    pub open_command_picker: CommandId,
101    /// MB.3: `q:` — open the command-line history picker.
102    pub open_history_picker: CommandId,
103    /// MB.5: `q/` / `q?` — open the search-line history picker.
104    pub open_search_history_picker: CommandId,
105    pub enter_command_line: CommandId,
106    /// MB.1: `command-line-mode` chords (submit / cancel / history /
107    /// completion / describe). Registered so the mode's table-form
108    /// keymap resolves them; each maps to an `AppEffect`.
109    pub command_line_submit: CommandId,
110    pub command_line_cancel: CommandId,
111    pub command_line_history_prev: CommandId,
112    pub command_line_history_next: CommandId,
113    pub command_line_complete: CommandId,
114    pub command_line_complete_prev: CommandId,
115    pub command_line_describe_under_cursor: CommandId,
116    pub command_line_toggle_expand: CommandId,
117    /// MB.5a: `action:search-line-submit` — `<CR>` on the `/`·`?` line.
118    pub search_line_submit: CommandId,
119    /// MB.5a: `action:search-line-cancel` — `<Esc>` / `<C-c>` on the `/`·`?` line.
120    pub search_line_cancel: CommandId,
121    /// `action:search-line-backspace` — `<BS>` on the `/`·`?` line.
122    /// Deletes a char; on an ALREADY-EMPTY pattern it cancels the
123    /// search instead (vim's behaviour, which MB.5a dropped when
124    /// `Action::SearchBackspace` became a no-op).
125    pub search_line_backspace: CommandId,
126    /// MB.5b: `action:search-line-history-prev` — `<C-p>` / `<Up>` on the `/`·`?` line.
127    pub search_line_history_prev: CommandId,
128    /// MB.5b: `action:search-line-history-next` — `<C-n>` / `<Down>` on the `/`·`?` line.
129    pub search_line_history_next: CommandId,
130    /// MB.5c: `action:search-line-toggle-expand` — `<C-x><C-e>` on the `/`·`?` line.
131    pub search_line_toggle_expand: CommandId,
132    /// `action:prompt-line-submit` — `<CR>` on a generic
133    /// `Effect::OpenPrompt` prompt.
134    pub prompt_line_submit: CommandId,
135    /// `action:prompt-line-cancel` — `<Esc>` / `<C-c>` on a generic
136    /// `Effect::OpenPrompt` prompt.
137    pub prompt_line_cancel: CommandId,
138    pub oil_navigate_up: CommandId,
139    pub reselect_last_visual: CommandId,
140    pub paste_after: CommandId,
141    pub paste_before: CommandId,
142    pub lsp_definition_request: CommandId,
143    pub lsp_declaration_request: CommandId,
144    pub lsp_type_definition_request: CommandId,
145    pub lsp_implementation_request: CommandId,
146    pub lsp_references_request: CommandId,
147    pub lsp_follow_link_at_cursor: CommandId,
148    pub enter_append: CommandId,
149    pub enter_insert_first_non_blank: CommandId,
150    pub enter_append_end_of_line: CommandId,
151    pub display_line_down: CommandId,
152    pub display_line_up: CommandId,
153    pub display_line_start: CommandId,
154    pub display_line_end: CommandId,
155    pub create_fold_from_visual: CommandId,
156    pub delete_char_backward: CommandId,
157    /// Insert-mode line editing (readline/vim). One `CommandId` per chord;
158    /// all resolve to `AppEffect::InsertLineEdit(kind)`.
159    pub insert_cursor_line_start: CommandId,
160    pub insert_cursor_line_end: CommandId,
161    pub insert_cursor_char_left: CommandId,
162    pub insert_cursor_char_right: CommandId,
163    pub insert_delete_word_backward: CommandId,
164    pub insert_delete_to_line_start: CommandId,
165    pub insert_kill_to_line_end: CommandId,
166    pub insert_indent_line: CommandId,
167    pub insert_dedent_line: CommandId,
168    pub completion_trigger: CommandId,
169    pub snippet_expand: CommandId,
170    /// L4b: `gl` in lsp-diagnostics-mode. Command-name registration
171    /// only — the handler body is `lsp-diagnostics-mode`'s mode-owned
172    /// `ActionHandlerRegistry` closure (emits `ShowDiagnosticsPopup`);
173    /// the `apply` below is a dead `Effect::None`, like `snippet_expand`.
174    pub lsp_diagnostic_popup: CommandId,
175    pub exit_visual: CommandId,
176    pub swap_visual_ends: CommandId,
177    pub replace_undo_last: CommandId,
178    pub enter_mode_insert: CommandId,
179    pub enter_mode_normal: CommandId,
180    pub enter_mode_replace: CommandId,
181    pub enter_visual_charwise: CommandId,
182    pub enter_visual_linewise: CommandId,
183    pub enter_visual_blockwise: CommandId,
184    /// SN.3d Select-mode entry chords `gh` / `gH` / `g<C-h>`.
185    pub enter_select_charwise: CommandId,
186    pub enter_select_linewise: CommandId,
187    pub enter_select_blockwise: CommandId,
188    pub enter_search_forward: CommandId,
189    pub enter_search_backward: CommandId,
190    pub search_word_under_cursor_forward: CommandId,
191    pub search_word_under_cursor_backward: CommandId,
192    pub jump_viewport_top: CommandId,
193    pub jump_viewport_middle: CommandId,
194    pub jump_viewport_bottom: CommandId,
195    pub scroll_cursor_to_top: CommandId,
196    pub scroll_cursor_to_center: CommandId,
197    pub scroll_cursor_to_bottom: CommandId,
198    pub h_scroll_right: CommandId,
199    pub h_scroll_left: CommandId,
200    pub h_scroll_half_right: CommandId,
201    pub h_scroll_half_left: CommandId,
202    pub h_scroll_cursor_left_edge: CommandId,
203    pub h_scroll_cursor_right_edge: CommandId,
204    pub join_lines_with_space: CommandId,
205    pub join_lines_bare: CommandId,
206    pub find_repeat_forward: CommandId,
207    pub find_repeat_reverse: CommandId,
208    pub insert_newline: CommandId,
209    pub insert_tab: CommandId,
210    pub overwrite_char: CommandId,
211    pub set_mark: CommandId,
212    pub jump_to_mark_line: CommandId,
213    pub jump_to_mark_exact: CommandId,
214    pub select_register: CommandId,
215    pub start_macro_record: CommandId,
216    pub play_macro: CommandId,
217    /// Slice 8.i.4.c: operator-prefix arms (`d`, `c`, `y`, `>`,
218    /// `<`, `gU`, `gu`, `g~`). Each chord binds a typed
219    /// `CommandInvocation` whose `ActionSpec` returns
220    /// `AppEffect::AbsorbOperatorPrefix(op_id)`. The App
221    /// handler latches `pending_count` -> `op_count` and
222    /// pushes the operator's chord prefix into
223    /// `App::partial_chord`. Replaces the legacy
224    /// `Action::SetPending(Pending::AfterOperator(_))` flow.
225    pub absorb_operator_delete: CommandId,
226    pub absorb_operator_change: CommandId,
227    pub absorb_operator_yank: CommandId,
228    pub absorb_operator_indent_right: CommandId,
229    pub absorb_operator_indent_left: CommandId,
230    /// IN.7: vim's `=` operator-pending prefix.
231    pub absorb_operator_reindent: CommandId,
232    /// RF.2: vim's `gq` and `gw` — one action, both chords.
233    pub absorb_operator_reflow: CommandId,
234    pub absorb_operator_upper: CommandId,
235    pub absorb_operator_lower: CommandId,
236    pub absorb_operator_toggle_case: CommandId,
237    pub absorb_operator_search: CommandId,
238    pub split_pane_horizontal: CommandId,
239    pub split_pane_vertical: CommandId,
240    pub close_pane: CommandId,
241    /// `<C-w>o` / `:only` / emacs `C-x 1` -- close every pane except
242    /// the active one. S3b (2026-06-22).
243    pub only_pane: CommandId,
244    /// ZP.2: `<C-w>z` -- toggle zoom on the active pane.
245    pub toggle_zoom_pane: CommandId,
246    pub navigate_pane_left: CommandId,
247    pub navigate_pane_down: CommandId,
248    pub navigate_pane_up: CommandId,
249    pub navigate_pane_right: CommandId,
250    pub next_pane: CommandId,
251    pub prev_pane: CommandId,
252    /// Issue #28 (2026-05-22): split-ratio adjustment IDs.
253    pub equalize_panes: CommandId,
254    pub grow_pane_height: CommandId,
255    pub shrink_pane_height: CommandId,
256    pub grow_pane_width: CommandId,
257    pub shrink_pane_width: CommandId,
258    /// Issue #32 (2026-05-22): picker open-target override
259    /// CommandIds. `<C-s>` / `<C-v>` / `<C-t>` on picker
260    /// overlays bind to these.
261    pub picker_accept_in_split: CommandId,
262    pub picker_accept_in_vsplit: CommandId,
263    pub picker_accept_in_tab: CommandId,
264    /// Issue #29 (2026-05-22): tab management IDs.
265    pub next_tab: CommandId,
266    pub prev_tab: CommandId,
267    pub new_tab: CommandId,
268    pub close_tab: CommandId,
269    /// Slice 3 (2026-05-22): tabonly + tabmove. `tabmove`'s
270    /// numeric target is carried by the dispatched
271    /// `Action::MoveTab(u32)` payload; the CommandId here just
272    /// names the action for keymap binding + plugin lookup.
273    pub only_tab: CommandId,
274    /// CM.3d (2026-07-22): graceful editor shutdown via
275    /// `AppEffect::Quit`. Reachable via `:q` and user bindings; the
276    /// hardcoded `<C-c>` → quit hatch was removed in CM.3d, and CG.1
277    /// gave that chord to [`Self::cancel`].
278    pub quit: CommandId,
279    /// CG.1 (2026-08-07): foreground cancellation. Bound to `<C-c>` at
280    /// the Builtin layer in every mode, and to `<C-g>` by
281    /// `emacs-keys-mode`.
282    pub cancel: CommandId,
283    pub move_tab: CommandId,
284    /// T4 (2026-05-25): `<C-w>T` — move the active pane to a
285    /// fresh tab. Dispatched as `Action::MovePaneToNewTab`
286    /// (handler in `Editor::do_move_pane_to_new_tab`).
287    pub move_pane_to_new_tab: CommandId,
288    /// Slice 8.i.4.e: completion-popup overlay actions
289    /// (registered into a minor-mode layer pushed when the
290    /// popup opens; popped on close). Each ID is the typed
291    /// `CommandInvocation` peer of the legacy
292    /// `Action::Completion*` variants.
293    pub completion_next: CommandId,
294    pub completion_prev: CommandId,
295    pub completion_accept: CommandId,
296    pub completion_cancel: CommandId,
297    pub completion_cancel_and_exit_insert: CommandId,
298    pub completion_toggle_docs: CommandId,
299    pub completion_docs_scroll_down: CommandId,
300    pub completion_docs_scroll_up: CommandId,
301    pub completion_accept_then_insert: CommandId,
302    /// YR.5: `<C-r>{reg}` — vim's insert-register.
303    pub insert_register: CommandId,
304    /// YR.5: `<C-r><C-r>` — the yank picker.
305    pub open_yank_picker: CommandId,
306    /// YR.6: `<C-x><C-o>` — the picker for the argument under the cursor.
307    pub open_arg_picker: CommandId,
308    /// CSM.K2: restrict the popup to a single completion source.
309    /// Args::String(source_id), e.g. `"gen:buffer-words"`. Bound
310    /// to popup-mode filter chords (`<C-b>`, `<C-o>`, `<C-f>`,
311    /// `<C-t>`, ...).
312    pub completion_filter_to_source: CommandId,
313    /// CSM.K2: clear the active source filter (`<C-Space>`).
314    pub completion_filter_clear: CommandId,
315    /// Slice 8.i.4.e: active-snippet overlay actions
316    /// (registered into a minor-mode layer pushed when a
317    /// snippet activates; popped on exit).
318    // CR.6 (2026-06-24): `diff_get`/`diff_put` ActionIds removed — the diff
319    // actions (incl. `action:diff-get`/`-put`) are registered by
320    // `lattice_diff::install()` now, not host `actions.rs`.
321    pub snippet_next_placeholder: CommandId,
322    pub snippet_prev_placeholder: CommandId,
323    pub snippet_leave: CommandId,
324    /// M.6.1 (2026-06-01): `gr` chord under
325    /// `MinorMode(project-search-mode)`. Re-run the
326    /// scan with the view's current query.
327    pub search_refresh: CommandId,
328    /// CM.3a (2026-07-22): `gr` chord under
329    /// `MajorMode(compilation-mode)`. Recompile — re-run the last
330    /// compilation command (`AppEffect::CompileRun { cmdline: None }`).
331    pub compilation_recompile: CommandId,
332    /// CM.3b (2026-07-22): `<CR>` chord under
333    /// `MajorMode(compilation-mode)`. Jump to the source location on
334    /// the cursor line. Mode-owned: `compilation-mode`'s per-buffer
335    /// `ActionHandlerRegistry` closure parses the line + emits
336    /// `AppEffect::CompileJumpToLocation`; the registered `ActionSpec`
337    /// body is a dead `Effect::None` (the handler always intercepts).
338    pub compilation_jump: CommandId,
339    /// Multibuffer-mode (2026-07-22): `<CR>` chord under
340    /// `MajorMode(multibuffer-mode)`. Jump to the source file/row
341    /// of the excerpt under cursor. Generic — works for ANY
342    /// multibuffer view regardless of provider (search, problems,
343    /// narrow, etc.). Mode-owned: `multibuffer-mode`'s per-buffer
344    /// `ActionHandlerRegistry` closure resolves the source + path;
345    /// the registered `ActionSpec` body is a dead `Effect::None`.
346    pub multibuffer_jump_to_source: CommandId,
347}
348
349/// Register every App-side action into `registry` and return
350/// the resulting [`ActionIds`]. Called once at App startup,
351/// after `lattice_grammar::builtins::populate` and
352/// `lattice_grammar::ex_commands::populate`.
353pub fn populate(registry: &mut CommandRegistry, builtins: &Builtins) -> ActionIds {
354    // CR.6 (2026-06-24): the diff conflict-resolution action shells moved to
355    // `lattice_diff::install()` (the "modes register commands" pattern) —
356    // they no longer register here.
357    ActionIds {
358        match_bracket: register_simple(
359            registry,
360            "action:match-bracket",
361            "Vim's `%`: jump to the matching bracket.",
362            AppEffect::MatchBracket,
363        ),
364        toggle_case_at_cursor: register_simple(
365            registry,
366            "action:toggle-case-at-cursor",
367            "Vim's `~`: toggle the case of the char at the cursor.",
368            AppEffect::ToggleCaseAtCursor,
369        ),
370        open_line_below: register_simple(
371            registry,
372            "action:open-line-below",
373            "Vim's `o`: open a new line below and enter Insert.",
374            AppEffect::OpenLineBelow,
375        ),
376        open_line_above: register_simple(
377            registry,
378            "action:open-line-above",
379            "Vim's `O`: open a new line above and enter Insert.",
380            AppEffect::OpenLineAbove,
381        ),
382        // L7: `K` is mode-owned. The `CommandId` still resolves (the
383        // chord binding + the `lsp-mode` handler registration key on it),
384        // but the `apply` is a dead `Effect::None` — the
385        // `ActionHandlerRegistry` closure intercepts first and emits
386        // `Effect::Lsp(LspRequest::Hover)`. Same shape as `snippet_expand`
387        // / `lsp_diagnostic_popup`.
388        lsp_hover_request: registry.register_action(
389            "action:lsp-hover",
390            "`K`: send `textDocument/hover` to attached LSP servers \
391             (mode-owned; `lsp-mode`'s handler emits `Effect::Lsp(LspRequest::Hover)`).",
392            ActionSpec {
393                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
394                args_schema: vec![],
395            },
396        ),
397        search_next: register_simple(
398            registry,
399            "action:search-next",
400            "Vim's `n`: re-run the last search forward.",
401            AppEffect::SearchNext,
402        ),
403        search_previous: register_simple(
404            registry,
405            "action:search-previous",
406            "Vim's `N`: re-run the last search in the reverse direction.",
407            AppEffect::SearchPrevious,
408        ),
409        error_next: register_simple(
410            registry,
411            "action:error-next",
412            "`]qq`: jump to the next error entry (wraps; echoes `no error list` when empty).",
413            AppEffect::ErrorNav {
414                target: lattice_grammar::ErrorTarget::Next,
415            },
416        ),
417        error_prev: register_simple(
418            registry,
419            "action:error-prev",
420            "`[qq`: jump to the previous error entry (wraps; echoes `no error list` when empty).",
421            AppEffect::ErrorNav {
422                target: lattice_grammar::ErrorTarget::Prev,
423            },
424        ),
425        error_first: register_simple(
426            registry,
427            "action:error-first",
428            "`[Q`: jump to the first error entry (`:cfirst`).",
429            AppEffect::ErrorNav {
430                target: lattice_grammar::ErrorTarget::First,
431            },
432        ),
433        error_last: register_simple(
434            registry,
435            "action:error-last",
436            "`]Q`: jump to the last error entry (`:clast`).",
437            AppEffect::ErrorNav {
438                target: lattice_grammar::ErrorTarget::Last,
439            },
440        ),
441        error_next_file: register_simple(
442            registry,
443            "action:error-next-file",
444            "`]qf`: jump to the first entry of the next file (`:cnextfile`; wraps).",
445            AppEffect::ErrorNav {
446                target: lattice_grammar::ErrorTarget::NextFile,
447            },
448        ),
449        error_prev_file: register_simple(
450            registry,
451            "action:error-prev-file",
452            "`[qf`: jump to the first entry of the previous file (`:cprevfile`; wraps).",
453            AppEffect::ErrorNav {
454                target: lattice_grammar::ErrorTarget::PrevFile,
455            },
456        ),
457        pane_history_back: register_simple(
458            registry,
459            "action:pane-history-back",
460            "`<C-6>`: step back through the active pane's buffer history.",
461            AppEffect::PaneHistoryBack,
462        ),
463        pane_history_forward: register_simple(
464            registry,
465            "action:pane-history-forward",
466            "`<C-7>`: step forward through the active pane's buffer history.",
467            AppEffect::PaneHistoryForward,
468        ),
469        jump_history_back: register_simple(
470            registry,
471            "action:jump-history-back",
472            "Vim's `<C-o>`: step backward through the position history.",
473            AppEffect::JumpHistoryBack,
474        ),
475        jump_history_forward: register_simple(
476            registry,
477            "action:jump-history-forward",
478            "Vim's `<C-i>`: step forward through the position history.",
479            AppEffect::JumpHistoryForward,
480        ),
481        walk_mark_history_back: register_simple(
482            registry,
483            "action:walk-mark-history-back",
484            "Vim's `g;`: walk backward through the mark history.",
485            AppEffect::WalkMarkHistoryBack,
486        ),
487        walk_mark_history_forward: register_simple(
488            registry,
489            "action:walk-mark-history-forward",
490            "Vim's `g,`: walk forward through the mark history.",
491            AppEffect::WalkMarkHistoryForward,
492        ),
493        tag_stack_pop: register_simple(
494            registry,
495            "action:tag-stack-pop",
496            "Vim's `<C-t>`: pop the tag stack and jump back to the prior origin.",
497            AppEffect::TagStackPop,
498        ),
499        open_fold_at_cursor: register_simple(
500            registry,
501            "action:open-fold-at-cursor",
502            "Vim's `zo`: open the fold containing the cursor.",
503            AppEffect::OpenFoldAtCursor,
504        ),
505        close_fold_at_cursor: register_simple(
506            registry,
507            "action:close-fold-at-cursor",
508            "Vim's `zc`: close the fold containing the cursor.",
509            AppEffect::CloseFoldAtCursor,
510        ),
511        toggle_fold_at_cursor: register_simple(
512            registry,
513            "action:toggle-fold-at-cursor",
514            "Vim's `za`: toggle the fold containing the cursor.",
515            AppEffect::ToggleFoldAtCursor,
516        ),
517        open_all_folds: register_simple(
518            registry,
519            "action:open-all-folds",
520            "Vim's `zR`: open every fold in the buffer.",
521            AppEffect::OpenAllFolds,
522        ),
523        close_all_folds: register_simple(
524            registry,
525            "action:close-all-folds",
526            "Vim's `zM`: close every fold in the buffer.",
527            AppEffect::CloseAllFolds,
528        ),
529        cycle_fold_at_cursor: register_simple(
530            registry,
531            "action:fold-cycle",
532            "org-cycle (`z<Space>`): cycle the fold under the cursor through \
533             FOLDED → CHILDREN → SUBTREE.",
534            AppEffect::CycleFoldAtCursor,
535        ),
536        cycle_folds_global: register_simple(
537            registry,
538            "action:fold-cycle-global",
539            "org-cycle (`z<Tab>`): cycle the whole buffer through \
540             OVERVIEW → CONTENTS → SHOW-ALL.",
541            AppEffect::CycleFoldsGlobal,
542        ),
543        goto_parent_fold: register_simple(
544            registry,
545            "action:fold-goto-parent",
546            "Move the cursor to the parent heading, one level up the fold \
547             hierarchy (`zp`).",
548            AppEffect::GotoParentFold,
549        ),
550        delete_fold_at_cursor: register_simple(
551            registry,
552            "action:delete-fold-at-cursor",
553            "Vim's `zd`: delete the fold containing the cursor.",
554            AppEffect::DeleteFoldAtCursor,
555        ),
556        open_folds_recursively: register_simple(
557            registry,
558            "action:open-folds-recursively",
559            "Vim's `zO`: open every fold containing the cursor and the folds \
560             nested inside them. In Visual, over every selected line.",
561            AppEffect::OpenFoldsRecursively,
562        ),
563        close_folds_recursively: register_simple(
564            registry,
565            "action:close-folds-recursively",
566            "Vim's `zC`: close every fold containing the cursor. In Visual, \
567             every fold containing a selected line.",
568            AppEffect::CloseFoldsRecursively,
569        ),
570        delete_folds_recursively: register_simple(
571            registry,
572            "action:delete-folds-recursively",
573            "Vim's `zD`: delete the innermost fold containing the cursor and the \
574             folds nested inside it. In Visual, the folds inside the selection.",
575            AppEffect::DeleteFoldsRecursively,
576        ),
577        goto_next_fold: register_simple(
578            registry,
579            "action:goto-next-fold",
580            "Vim's `zj`: move cursor to the start of the next fold.",
581            AppEffect::GotoNextFold,
582        ),
583        goto_prev_fold: register_simple(
584            registry,
585            "action:goto-prev-fold",
586            "Vim's `zk`: move cursor to the end of the previous fold.",
587            AppEffect::GotoPrevFold,
588        ),
589        toggle_fold_enable: register_simple(
590            registry,
591            "action:toggle-fold-enable",
592            "Vim's `zi`: toggle the `foldenable` option.",
593            AppEffect::ToggleFoldEnable,
594        ),
595        undo: register_simple(
596            registry,
597            "action:undo",
598            "Vim's `u`: undo the last buffer change.",
599            AppEffect::Undo,
600        ),
601        redo: register_simple(
602            registry,
603            "action:redo",
604            "Vim's `<C-r>`: redo the last undone change.",
605            AppEffect::Redo,
606        ),
607        repeat_last_change: register_simple(
608            registry,
609            "action:repeat-last-change",
610            "Vim's `.`: repeat the last change.",
611            AppEffect::RepeatLastChange,
612        ),
613        page_down: register_simple(
614            registry,
615            "action:page-down",
616            "Vim's `<C-f>`: scroll the viewport down one page.",
617            AppEffect::PageDown,
618        ),
619        half_page_down: register_simple(
620            registry,
621            "action:half-page-down",
622            "Vim's `<C-d>`: scroll the view and the cursor down by `scroll` lines.",
623            AppEffect::HalfPageDown,
624        ),
625        half_page_up: register_simple(
626            registry,
627            "action:half-page-up",
628            "Vim's `<C-u>`: scroll the view and the cursor up by `scroll` lines.",
629            AppEffect::HalfPageUp,
630        ),
631        page_up: register_simple(
632            registry,
633            "action:page-up",
634            "Vim's `<C-b>`: scroll the viewport up one page.",
635            AppEffect::PageUp,
636        ),
637        scroll_line_up: register_simple(
638            registry,
639            "action:scroll-line-up",
640            "Vim's `<C-y>`: scroll viewport up one line.",
641            AppEffect::ScrollLineUp,
642        ),
643        scroll_line_down: register_simple(
644            registry,
645            "action:scroll-line-down",
646            "Vim's `<C-e>`: scroll viewport down one line.",
647            AppEffect::ScrollLineDown,
648        ),
649        redraw_screen: register_simple(
650            registry,
651            "action:redraw-screen",
652            "Vim's `<C-l>`: force a full screen redraw.",
653            AppEffect::RedrawScreen,
654        ),
655        open_command_picker: register_simple(
656            registry,
657            "action:open-command-picker",
658            "Vim's `:` / Emacs' `M-x`: open the command picker.",
659            AppEffect::OpenCommandPicker,
660        ),
661        open_history_picker: register_simple(
662            registry,
663            "action:open-history-picker",
664            "Vim's `q:`: open the command-line history picker (accept loads into the `:` line, does not execute).",
665            AppEffect::OpenHistoryPicker,
666        ),
667        open_search_history_picker: register_simple(
668            registry,
669            "action:open-search-history-picker",
670            "Vim's `q/` / `q?`: open the search-line history picker (accept loads into the `/` line, does not execute).",
671            AppEffect::OpenSearchHistoryPicker,
672        ),
673        enter_command_line: register_simple(
674            registry,
675            "action:enter-command-line",
676            "Vim's `:`: enter the command-line minibuffer.",
677            AppEffect::EnterCommandLine,
678        ),
679        command_line_submit: register_simple(
680            registry,
681            "action:command-line-submit",
682            "MB.1: submit the `:` command line (or accept the open completion candidate).",
683            AppEffect::CommandLineSubmit,
684        ),
685        command_line_cancel: register_simple(
686            registry,
687            "action:command-line-cancel",
688            "MB.1: cancel the `:` command line (or dismiss the completion popup first).",
689            AppEffect::CommandLineCancel,
690        ),
691        command_line_history_prev: register_simple(
692            registry,
693            "action:command-line-history-prev",
694            "MB.1: previous `:` history entry (or previous completion candidate).",
695            AppEffect::CommandLineHistoryPrev,
696        ),
697        command_line_history_next: register_simple(
698            registry,
699            "action:command-line-history-next",
700            "MB.1: next `:` history entry (or next completion candidate).",
701            AppEffect::CommandLineHistoryNext,
702        ),
703        command_line_complete: register_simple(
704            registry,
705            "action:command-line-complete",
706            "MB.1: open the `:` completion popup or advance the selection.",
707            AppEffect::CommandLineComplete,
708        ),
709        command_line_complete_prev: register_simple(
710            registry,
711            "action:command-line-complete-prev",
712            "MB.1: previous `:` completion candidate.",
713            AppEffect::CommandLineCompletePrev,
714        ),
715        command_line_describe_under_cursor: register_simple(
716            registry,
717            "action:command-line-describe-under-cursor",
718            "MB.1: describe the command / arg under the `:` cursor.",
719            AppEffect::CommandLineDescribeUnderCursor,
720        ),
721        command_line_toggle_expand: register_simple(
722            registry,
723            "action:command-line-toggle-expand",
724            "MB.2: `<C-x><C-e>`: expand the `:` line into the full-modal \
725             mini-buffer band, or collapse it back to the one-row line.",
726            AppEffect::CommandLineToggleExpand,
727        ),
728        search_line_submit: register_simple(
729            registry,
730            "action:search-line-submit",
731            "MB.5a: `<CR>` on the `/`·`?` search line — submit the search \
732             pattern.",
733            AppEffect::SearchLineSubmit,
734        ),
735        search_line_cancel: register_simple(
736            registry,
737            "action:search-line-cancel",
738            "MB.5a: `<Esc>` / `<C-c>` on the `/`·`?` search line — cancel \
739             the search and restore the prior editing buffer.",
740            AppEffect::SearchLineCancel,
741        ),
742        search_line_backspace: register_simple(
743            registry,
744            "action:search-line-backspace",
745            "`<BS>` on the `/`·`?` search line — delete the character \
746             before the cursor, or cancel the search when the pattern \
747             is already empty (vim's behaviour).",
748            AppEffect::SearchLineBackspace,
749        ),
750        search_line_history_prev: register_simple(
751            registry,
752            "action:search-line-history-prev",
753            "MB.5b: `<C-p>` / `<Up>` on the `/`·`?` search line — previous \
754             search history entry.",
755            AppEffect::SearchLineHistoryPrev,
756        ),
757        search_line_history_next: register_simple(
758            registry,
759            "action:search-line-history-next",
760            "MB.5b: `<C-n>` / `<Down>` on the `/`·`?` search line — next \
761             search history entry.",
762            AppEffect::SearchLineHistoryNext,
763        ),
764        search_line_toggle_expand: register_simple(
765            registry,
766            "action:search-line-toggle-expand",
767            "MB.5c: `<C-x><C-e>` on the `/`·`?` search line — toggle the \
768             expanded tier-2 mini-buffer band.",
769            AppEffect::SearchLineToggleExpand,
770        ),
771        prompt_line_submit: register_simple(
772            registry,
773            "action:prompt-line-submit",
774            "Submit a generic `Effect::OpenPrompt` prompt.",
775            AppEffect::PromptLineSubmit,
776        ),
777        prompt_line_cancel: register_simple(
778            registry,
779            "action:prompt-line-cancel",
780            "Cancel a generic `Effect::OpenPrompt` prompt.",
781            AppEffect::PromptLineCancel,
782        ),
783        oil_navigate_up: register_simple(
784            registry,
785            "action:oil-navigate-up",
786            "Lattice's `-`: open / step up in the oil-style directory view.",
787            AppEffect::OilNavigateUp,
788        ),
789        reselect_last_visual: register_simple(
790            registry,
791            "action:reselect-last-visual",
792            "Vim's `gv`: reselect the last Visual selection.",
793            AppEffect::ReselectLastVisual,
794        ),
795        paste_after: register_simple(
796            registry,
797            "action:paste-after",
798            "Vim's `p`: paste the unnamed register's contents after the cursor.",
799            AppEffect::PasteAfter,
800        ),
801        paste_before: register_simple(
802            registry,
803            "action:paste-before",
804            "Vim's `P`: paste the unnamed register's contents before the cursor.",
805            AppEffect::PasteBefore,
806        ),
807        // L7: the 6 nav chords are mode-owned. Each `CommandId` still
808        // resolves (chord binding + `lsp-mode` handler registration key on
809        // it) but carries a dead `Effect::None` apply — the
810        // `ActionHandlerRegistry` closure intercepts first and emits
811        // `Effect::Lsp(LspRequest::…)`. Same shape as `snippet_expand`.
812        lsp_definition_request: registry.register_action(
813            "action:lsp-definition",
814            "`gd`: send `textDocument/definition` to attached LSP servers \
815             (mode-owned; emits `Effect::Lsp(LspRequest::Definition)`).",
816            ActionSpec {
817                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
818                args_schema: vec![],
819            },
820        ),
821        lsp_declaration_request: registry.register_action(
822            "action:lsp-declaration",
823            "`gD`: send `textDocument/declaration` to attached LSP servers \
824             (mode-owned; emits `Effect::Lsp(LspRequest::Declaration)`).",
825            ActionSpec {
826                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
827                args_schema: vec![],
828            },
829        ),
830        lsp_type_definition_request: registry.register_action(
831            "action:lsp-type-definition",
832            "`gy`: send `textDocument/typeDefinition` to attached LSP servers \
833             (mode-owned; emits `Effect::Lsp(LspRequest::TypeDefinition)`).",
834            ActionSpec {
835                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
836                args_schema: vec![],
837            },
838        ),
839        lsp_implementation_request: registry.register_action(
840            "action:lsp-implementation",
841            "`gI`: send `textDocument/implementation` to attached LSP servers \
842             (mode-owned; emits `Effect::Lsp(LspRequest::Implementation)`).",
843            ActionSpec {
844                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
845                args_schema: vec![],
846            },
847        ),
848        lsp_references_request: registry.register_action(
849            "action:lsp-references",
850            "`gr`: send `textDocument/references` to attached LSP servers \
851             (mode-owned; emits `Effect::Lsp(LspRequest::References)`).",
852            ActionSpec {
853                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
854                args_schema: vec![],
855            },
856        ),
857        lsp_follow_link_at_cursor: registry.register_action(
858            "action:lsp-follow-link",
859            "`gx`: follow the `textDocument/documentLink` covering the cursor \
860             (mode-owned; emits `Effect::Lsp(LspRequest::FollowLink)`).",
861            ActionSpec {
862                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
863                args_schema: vec![],
864            },
865        ),
866        enter_append: register_simple(
867            registry,
868            "action:enter-append",
869            "Vim's `a`: move right one byte and enter Insert.",
870            AppEffect::EnterAppend,
871        ),
872        insert_cursor_line_start: register_simple(
873            registry,
874            "action:insert-cursor-line-start",
875            "Insert `<C-a>`: move the caret to the start of the line (readline).",
876            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::CursorLineStart),
877        ),
878        insert_cursor_line_end: register_simple(
879            registry,
880            "action:insert-cursor-line-end",
881            "Insert `<C-e>`: move the caret to the end of the line (readline).",
882            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::CursorLineEnd),
883        ),
884        insert_cursor_char_left: register_simple(
885            registry,
886            "action:insert-cursor-char-left",
887            "Insert `<C-b>`: move the caret one character left (readline).",
888            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::CursorCharLeft),
889        ),
890        insert_cursor_char_right: register_simple(
891            registry,
892            "action:insert-cursor-char-right",
893            "Insert `<C-f>`: move the caret one character right (readline).",
894            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::CursorCharRight),
895        ),
896        insert_delete_word_backward: register_simple(
897            registry,
898            "action:insert-delete-word-backward",
899            "Insert `<C-w>`: delete the word before the caret (readline/vim).",
900            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::DeleteWordBackward),
901        ),
902        insert_delete_to_line_start: register_simple(
903            registry,
904            "action:insert-delete-to-line-start",
905            "Insert `<C-u>`: delete from the line start to the caret (readline/vim).",
906            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::DeleteToLineStart),
907        ),
908        insert_kill_to_line_end: register_simple(
909            registry,
910            "action:insert-kill-to-line-end",
911            "Insert `<C-k>`: delete from the caret to the line end (readline).",
912            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::KillToLineEnd),
913        ),
914        insert_indent_line: register_simple(
915            registry,
916            "action:insert-indent-line",
917            "Insert `<C-t>`: indent the current line by one shiftwidth (vim).",
918            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::IndentLine),
919        ),
920        insert_dedent_line: register_simple(
921            registry,
922            "action:insert-dedent-line",
923            "Insert `<C-d>`: dedent the current line by one shiftwidth (vim).",
924            AppEffect::InsertLineEdit(lattice_grammar::InsertLineEdit::DedentLine),
925        ),
926        enter_insert_first_non_blank: register_simple(
927            registry,
928            "action:enter-insert-first-non-blank",
929            "Vim's `I`: move to first non-blank of line and enter Insert.",
930            AppEffect::EnterInsertFirstNonBlank,
931        ),
932        enter_append_end_of_line: register_simple(
933            registry,
934            "action:enter-append-end-of-line",
935            "Vim's `A`: move to end of line and enter Insert.",
936            AppEffect::EnterAppendEndOfLine,
937        ),
938        display_line_down: register_simple(
939            registry,
940            "action:display-line-down",
941            "Vim's `gj`: move down one display line (wrap segment).",
942            AppEffect::DisplayLineDown,
943        ),
944        display_line_up: register_simple(
945            registry,
946            "action:display-line-up",
947            "Vim's `gk`: move up one display line (wrap segment).",
948            AppEffect::DisplayLineUp,
949        ),
950        display_line_start: register_simple(
951            registry,
952            "action:display-line-start",
953            "Vim's `g0`: move to the start of the current display segment.",
954            AppEffect::DisplayLineStart,
955        ),
956        display_line_end: register_simple(
957            registry,
958            "action:display-line-end",
959            "Vim's `g$`: move to the end of the current display segment.",
960            AppEffect::DisplayLineEnd,
961        ),
962        create_fold_from_visual: register_simple(
963            registry,
964            "action:create-fold-from-visual",
965            "Vim's `zf`: create a fold from the most recent Visual selection.",
966            AppEffect::CreateFoldFromVisual,
967        ),
968        delete_char_backward: register_simple(
969            registry,
970            "action:delete-char-backward",
971            "Insert mode's `<BS>`: delete the byte before the cursor.",
972            AppEffect::DeleteCharBackward,
973        ),
974        completion_trigger: register_simple(
975            registry,
976            "action:completion-trigger",
977            "Insert mode's `<C-Space>` / `<C-x><C-o>`: trigger the completion popup.",
978            AppEffect::CompletionTrigger,
979        ),
980        // SN.3c.1 (2026-06-14): the CommandSpec stays (the
981        // `snippet-mode` chord binds it + the mode's global
982        // `ActionHandlerRegistry` handler keys on it), but its
983        // `apply` is now a dead `Effect::None`: the handler always
984        // intercepts before the grammar Action gate, so this body
985        // never runs. Kept (not deleted) so the `CommandId` resolves
986        // for the chord binding + handler registration.
987        snippet_expand: registry.register_action(
988            "action:snippet-expand",
989            "Insert mode's `<C-x><C-s>`: direct snippet expansion (mode-owned; \
990             `snippet-mode`'s handler emits `Effect::ExpandSnippet`).",
991            ActionSpec {
992                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
993                args_schema: vec![],
994            },
995        ),
996        // L4b: command-name registration for lsp-diagnostics-mode's
997        // `gl`. The mode's `ActionHandlerRegistry` closure intercepts
998        // before this `apply`, so the body is a dead `Effect::None`
999        // (same shape as `snippet_expand`). Kept so the `CommandId`
1000        // resolves for the chord binding + handler registration.
1001        lsp_diagnostic_popup: registry.register_action(
1002            "action:lsp-diagnostic-popup",
1003            "lsp-diagnostics-mode's `gl`: show the cursor line's diagnostics in a \
1004             cursor-anchored popup (mode-owned; the handler emits \
1005             `Effect::ShowDiagnosticsPopup`).",
1006            ActionSpec {
1007                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
1008                args_schema: vec![],
1009            },
1010        ),
1011        exit_visual: register_simple(
1012            registry,
1013            "action:exit-visual",
1014            "Visual mode's `<Esc>` / `v` / `V`: exit Visual to Normal.",
1015            AppEffect::ExitVisual,
1016        ),
1017        swap_visual_ends: register_simple(
1018            registry,
1019            "action:swap-visual-ends",
1020            "Visual mode's `o`: swap the cursor to the other end of the selection.",
1021            AppEffect::SwapVisualEnds,
1022        ),
1023        replace_undo_last: register_simple(
1024            registry,
1025            "action:replace-undo-last",
1026            "Replace mode's `<BS>`: undo the last overwritten char.",
1027            AppEffect::ReplaceUndoLast,
1028        ),
1029        enter_mode_insert: register_simple(
1030            registry,
1031            "action:enter-mode-insert",
1032            "Vim's `i`: enter Insert mode at the cursor.",
1033            AppEffect::EnterMode(ModalState::Insert),
1034        ),
1035        enter_mode_normal: register_simple(
1036            registry,
1037            "action:enter-mode-normal",
1038            "Vim's `<Esc>` (from Insert / Replace): return to Normal mode.",
1039            AppEffect::EnterMode(ModalState::Normal),
1040        ),
1041        enter_mode_replace: register_simple(
1042            registry,
1043            "action:enter-mode-replace",
1044            "Vim's `R`: enter Replace mode.",
1045            AppEffect::EnterMode(ModalState::Replace),
1046        ),
1047        enter_visual_charwise: register_simple(
1048            registry,
1049            "action:enter-visual-charwise",
1050            "Vim's `v`: enter charwise Visual at the current cursor.",
1051            AppEffect::EnterVisual(VisualKind::Charwise),
1052        ),
1053        enter_visual_linewise: register_simple(
1054            registry,
1055            "action:enter-visual-linewise",
1056            "Vim's `V`: enter linewise Visual at the current cursor.",
1057            AppEffect::EnterVisual(VisualKind::Linewise),
1058        ),
1059        enter_visual_blockwise: register_simple(
1060            registry,
1061            "action:enter-visual-blockwise",
1062            "Vim's `<C-v>` / `<C-q>`: enter blockwise Visual at the current cursor.",
1063            AppEffect::EnterVisual(VisualKind::Blockwise),
1064        ),
1065        enter_select_charwise: register_simple(
1066            registry,
1067            "action:enter-select-charwise",
1068            "Vim's `gh`: enter charwise Select at the current cursor (SN.3d).",
1069            AppEffect::EnterSelect(VisualKind::Charwise),
1070        ),
1071        enter_select_linewise: register_simple(
1072            registry,
1073            "action:enter-select-linewise",
1074            "Vim's `gH`: enter linewise Select at the current cursor (SN.3d).",
1075            AppEffect::EnterSelect(VisualKind::Linewise),
1076        ),
1077        enter_select_blockwise: register_simple(
1078            registry,
1079            "action:enter-select-blockwise",
1080            "Vim's `g<C-h>`: enter blockwise Select at the current cursor (SN.3d).",
1081            AppEffect::EnterSelect(VisualKind::Blockwise),
1082        ),
1083        enter_search_forward: register_simple(
1084            registry,
1085            "action:enter-search-forward",
1086            "Vim's `/`: enter the Search minibuffer searching forward.",
1087            AppEffect::EnterSearch(SearchDirection::Forward),
1088        ),
1089        enter_search_backward: register_simple(
1090            registry,
1091            "action:enter-search-backward",
1092            "Vim's `?`: enter the Search minibuffer searching backward.",
1093            AppEffect::EnterSearch(SearchDirection::Backward),
1094        ),
1095        search_word_under_cursor_forward: register_simple(
1096            registry,
1097            "action:search-word-under-cursor-forward",
1098            "Vim's `*`: search forward for the word under the cursor.",
1099            AppEffect::SearchWordUnderCursor(SearchDirection::Forward),
1100        ),
1101        search_word_under_cursor_backward: register_simple(
1102            registry,
1103            "action:search-word-under-cursor-backward",
1104            "Vim's `#`: search backward for the word under the cursor.",
1105            AppEffect::SearchWordUnderCursor(SearchDirection::Backward),
1106        ),
1107        jump_viewport_top: register_simple(
1108            registry,
1109            "action:jump-viewport-top",
1110            "Vim's `H`: jump cursor to the top visible line.",
1111            AppEffect::JumpViewport(ViewportPos::Top),
1112        ),
1113        jump_viewport_middle: register_simple(
1114            registry,
1115            "action:jump-viewport-middle",
1116            "Vim's `M`: jump cursor to the middle visible line.",
1117            AppEffect::JumpViewport(ViewportPos::Middle),
1118        ),
1119        jump_viewport_bottom: register_simple(
1120            registry,
1121            "action:jump-viewport-bottom",
1122            "Vim's `L`: jump cursor to the bottom visible line.",
1123            AppEffect::JumpViewport(ViewportPos::Bottom),
1124        ),
1125        scroll_cursor_to_top: register_simple(
1126            registry,
1127            "action:scroll-cursor-to-top",
1128            "Vim's `zt`: scroll viewport so the cursor's line sits at the top.",
1129            AppEffect::ScrollCursorTo(ScrollPos::Top),
1130        ),
1131        scroll_cursor_to_center: register_simple(
1132            registry,
1133            "action:scroll-cursor-to-center",
1134            "Vim's `zz`: scroll viewport so the cursor's line sits at the centre.",
1135            AppEffect::ScrollCursorTo(ScrollPos::Center),
1136        ),
1137        scroll_cursor_to_bottom: register_simple(
1138            registry,
1139            "action:scroll-cursor-to-bottom",
1140            "Vim's `zb`: scroll viewport so the cursor's line sits at the bottom.",
1141            AppEffect::ScrollCursorTo(ScrollPos::Bottom),
1142        ),
1143        h_scroll_right: register_simple(
1144            registry,
1145            "action:h-scroll-right",
1146            "Vim's `zl`: scroll the view right by [count] columns (wrap off).",
1147            AppEffect::HorizontalScroll(HScroll::Columns { right: true }),
1148        ),
1149        h_scroll_left: register_simple(
1150            registry,
1151            "action:h-scroll-left",
1152            "Vim's `zh`: scroll the view left by [count] columns (wrap off).",
1153            AppEffect::HorizontalScroll(HScroll::Columns { right: false }),
1154        ),
1155        h_scroll_half_right: register_simple(
1156            registry,
1157            "action:h-scroll-half-right",
1158            "Vim's `zL`: scroll the view right by half the body width.",
1159            AppEffect::HorizontalScroll(HScroll::HalfScreen { right: true }),
1160        ),
1161        h_scroll_half_left: register_simple(
1162            registry,
1163            "action:h-scroll-half-left",
1164            "Vim's `zH`: scroll the view left by half the body width.",
1165            AppEffect::HorizontalScroll(HScroll::HalfScreen { right: false }),
1166        ),
1167        h_scroll_cursor_left_edge: register_simple(
1168            registry,
1169            "action:h-scroll-cursor-left-edge",
1170            "Vim's `zs`: scroll so the cursor's column sits at the left edge.",
1171            AppEffect::HorizontalScroll(HScroll::CursorToEdge { end: false }),
1172        ),
1173        h_scroll_cursor_right_edge: register_simple(
1174            registry,
1175            "action:h-scroll-cursor-right-edge",
1176            "Vim's `ze`: scroll so the cursor's column sits at the right edge.",
1177            AppEffect::HorizontalScroll(HScroll::CursorToEdge { end: true }),
1178        ),
1179        join_lines_with_space: register_simple(
1180            registry,
1181            "action:join-lines-with-space",
1182            "Vim's `J`: join the current line with the next using a single space.",
1183            AppEffect::JoinLines { with_space: true },
1184        ),
1185        join_lines_bare: register_simple(
1186            registry,
1187            "action:join-lines-bare",
1188            "Vim's `gJ`: join the current line with the next without inserting a space.",
1189            AppEffect::JoinLines { with_space: false },
1190        ),
1191        find_repeat_forward: register_simple(
1192            registry,
1193            "action:find-repeat-forward",
1194            "Vim's `;`: repeat the most recent f/F/t/T find in the same direction.",
1195            AppEffect::FindRepeat { reverse: false },
1196        ),
1197        find_repeat_reverse: register_simple(
1198            registry,
1199            "action:find-repeat-reverse",
1200            "Vim's `,`: repeat the most recent f/F/t/T find in the reverse direction.",
1201            AppEffect::FindRepeat { reverse: true },
1202        ),
1203        insert_newline: register_simple(
1204            registry,
1205            "action:insert-newline",
1206            "Insert / Replace mode's `<CR>`: insert a literal newline at the cursor.",
1207            AppEffect::InsertNewline,
1208        ),
1209        insert_tab: register_simple(
1210            registry,
1211            "action:insert-tab",
1212            "Insert mode's `<Tab>`: insert a literal tab at the cursor.",
1213            AppEffect::InsertTab,
1214        ),
1215        overwrite_char: register_action(
1216            registry,
1217            "action:overwrite-char",
1218            "Replace mode's bare-printable wildcard: overwrite the byte at the cursor with the captured char.",
1219            captured_char_action(|c| Some(AppEffect::OverwriteChar(c))),
1220        ),
1221        set_mark: register_action(
1222            registry,
1223            "action:set-mark",
1224            "Vim's `m<X>`: set mark `<X>` at the cursor (alphanumeric only).",
1225            captured_char_action(|c| {
1226                if c.is_ascii_alphanumeric() {
1227                    Some(AppEffect::SetMark(c))
1228                } else {
1229                    None
1230                }
1231            }),
1232        ),
1233        jump_to_mark_line: register_action(
1234            registry,
1235            "action:jump-to-mark-line",
1236            "Vim's `'<X>`: jump cursor to the line of mark `<X>` (alphanumeric only).",
1237            captured_char_action(|c| {
1238                if c.is_ascii_alphanumeric() {
1239                    Some(AppEffect::JumpToMarkLine(c))
1240                } else {
1241                    None
1242                }
1243            }),
1244        ),
1245        jump_to_mark_exact: register_action(
1246            registry,
1247            "action:jump-to-mark-exact",
1248            "Vim's `` `<X> ``: jump cursor to the exact position of mark `<X>`.",
1249            captured_char_action(|c| {
1250                if c.is_ascii_alphanumeric() {
1251                    Some(AppEffect::JumpToMarkExact(c))
1252                } else {
1253                    None
1254                }
1255            }),
1256        ),
1257        select_register: register_action(
1258            registry,
1259            "action:select-register",
1260            "Vim's `\"<X>`: select named register `<X>` for the next yank / paste / delete.",
1261            captured_char_action(|c| Register::from_input_char(c).map(AppEffect::SelectRegister)),
1262        ),
1263        start_macro_record: register_action(
1264            registry,
1265            "action:start-macro-record",
1266            "Vim's `q<X>`: start recording a macro into register `<X>` (alphanumeric only).",
1267            captured_char_action(|c| {
1268                if c.is_ascii_alphanumeric() {
1269                    Some(AppEffect::StartMacroRecord(c))
1270                } else {
1271                    None
1272                }
1273            }),
1274        ),
1275        play_macro: register_action(
1276            registry,
1277            "action:play-macro",
1278            "Vim's `@<X>`: play the macro stored in register `<X>`. `@@` replays the most recent macro.",
1279            captured_char_action(|c| {
1280                if c == '@' {
1281                    Some(AppEffect::PlayLastMacro)
1282                } else if c.is_ascii_alphanumeric() {
1283                    Some(AppEffect::PlayMacro(c))
1284                } else {
1285                    None
1286                }
1287            }),
1288        ),
1289        absorb_operator_delete: register_operator_prefix(
1290            registry,
1291            "action:absorb-operator-delete",
1292            "Vim's `d`: arm operator-pending for delete.",
1293            builtins.delete,
1294        ),
1295        absorb_operator_change: register_operator_prefix(
1296            registry,
1297            "action:absorb-operator-change",
1298            "Vim's `c`: arm operator-pending for change.",
1299            builtins.change,
1300        ),
1301        absorb_operator_yank: register_operator_prefix(
1302            registry,
1303            "action:absorb-operator-yank",
1304            "Vim's `y`: arm operator-pending for yank.",
1305            builtins.yank,
1306        ),
1307        absorb_operator_indent_right: register_operator_prefix(
1308            registry,
1309            "action:absorb-operator-indent-right",
1310            "Vim's `>`: arm operator-pending for indent-right.",
1311            builtins.indent_right,
1312        ),
1313        absorb_operator_indent_left: register_operator_prefix(
1314            registry,
1315            "action:absorb-operator-indent-left",
1316            "Vim's `<`: arm operator-pending for indent-left.",
1317            builtins.indent_left,
1318        ),
1319        absorb_operator_reindent: register_operator_prefix(
1320            registry,
1321            "action:absorb-operator-reindent",
1322            "Vim's `=`: arm operator-pending for reindent.",
1323            builtins.reindent,
1324        ),
1325        // RF.2: ONE prefix action behind BOTH `gq` and `gw`. Vim's
1326        // two operators differ only in cursor placement, which is two
1327        // mnemonics for one operation; keeping one action means the
1328        // doubled forms (`gqq`, `gww`, and the mixed `gqw` / `gwq`) all
1329        // resolve to the same current-line reflow with no extra wiring.
1330        absorb_operator_reflow: register_operator_prefix(
1331            registry,
1332            "action:absorb-operator-reflow",
1333            "Vim's `gq` / `gw`: arm operator-pending for reflow.",
1334            builtins.reflow,
1335        ),
1336        absorb_operator_upper: register_operator_prefix(
1337            registry,
1338            "action:absorb-operator-upper",
1339            "Vim's `gU`: arm operator-pending for uppercase.",
1340            builtins.upper,
1341        ),
1342        absorb_operator_lower: register_operator_prefix(
1343            registry,
1344            "action:absorb-operator-lower",
1345            "Vim's `gu`: arm operator-pending for lowercase.",
1346            builtins.lower,
1347        ),
1348        absorb_operator_toggle_case: register_operator_prefix(
1349            registry,
1350            "action:absorb-operator-toggle-case",
1351            "Vim's `g~`: arm operator-pending for toggle-case.",
1352            builtins.toggle_case,
1353        ),
1354        absorb_operator_search: register_operator_prefix(
1355            registry,
1356            "action:absorb-operator-search",
1357            "Lattice's `g/`: arm operator-pending for project-search.",
1358            builtins.search,
1359        ),
1360        split_pane_horizontal: register_simple(
1361            registry,
1362            "action:split-pane-horizontal",
1363            "Vim's `<C-w>s`: split the active pane horizontally.",
1364            AppEffect::SplitPaneHorizontal,
1365        ),
1366        split_pane_vertical: register_simple(
1367            registry,
1368            "action:split-pane-vertical",
1369            "Vim's `<C-w>v`: split the active pane vertically.",
1370            AppEffect::SplitPaneVertical,
1371        ),
1372        close_pane: register_simple(
1373            registry,
1374            "action:close-pane",
1375            "Vim's `<C-w>c`: close the active pane.",
1376            AppEffect::ClosePane,
1377        ),
1378        only_pane: register_simple(
1379            registry,
1380            "action:only-pane",
1381            "Vim's `<C-w>o` / `:only`: close every pane except the active one.",
1382            AppEffect::OnlyPane,
1383        ),
1384        toggle_zoom_pane: register_simple(
1385            registry,
1386            "action:toggle-zoom-pane",
1387            "`<C-w>z` / `:zoom-pane`: toggle zoom on the active pane.",
1388            AppEffect::ToggleZoomPane,
1389        ),
1390        navigate_pane_left: register_simple(
1391            registry,
1392            "action:navigate-pane-left",
1393            "Vim's `<C-w>h`: move focus to the pane on the left.",
1394            AppEffect::NavigatePane(PaneDirection::Left),
1395        ),
1396        navigate_pane_down: register_simple(
1397            registry,
1398            "action:navigate-pane-down",
1399            "Vim's `<C-w>j`: move focus to the pane below.",
1400            AppEffect::NavigatePane(PaneDirection::Down),
1401        ),
1402        navigate_pane_up: register_simple(
1403            registry,
1404            "action:navigate-pane-up",
1405            "Vim's `<C-w>k`: move focus to the pane above.",
1406            AppEffect::NavigatePane(PaneDirection::Up),
1407        ),
1408        navigate_pane_right: register_simple(
1409            registry,
1410            "action:navigate-pane-right",
1411            "Vim's `<C-w>l`: move focus to the pane on the right.",
1412            AppEffect::NavigatePane(PaneDirection::Right),
1413        ),
1414        next_pane: register_simple(
1415            registry,
1416            "action:next-pane",
1417            "Vim's `<C-w>w`: cycle focus to the next pane.",
1418            AppEffect::NextPane,
1419        ),
1420        prev_pane: register_simple(
1421            registry,
1422            "action:prev-pane",
1423            "Vim's `<C-w>W`: cycle focus to the previous pane.",
1424            AppEffect::PrevPane,
1425        ),
1426        // Issue #28 (2026-05-22): split-ratio adjustment.
1427        equalize_panes: register_simple(
1428            registry,
1429            "action:equalize-panes",
1430            "Vim's `<C-w>=`: reset every split's ratio to 0.5.",
1431            AppEffect::EqualizePanes,
1432        ),
1433        grow_pane_height: register_simple(
1434            registry,
1435            "action:grow-pane-height",
1436            "Vim's `<C-w>+`: grow the active pane vertically.",
1437            AppEffect::GrowPaneHeight,
1438        ),
1439        shrink_pane_height: register_simple(
1440            registry,
1441            "action:shrink-pane-height",
1442            "Vim's `<C-w>-`: shrink the active pane vertically.",
1443            AppEffect::ShrinkPaneHeight,
1444        ),
1445        grow_pane_width: register_simple(
1446            registry,
1447            "action:grow-pane-width",
1448            "Vim's `<C-w>>`: grow the active pane horizontally.",
1449            AppEffect::GrowPaneWidth,
1450        ),
1451        shrink_pane_width: register_simple(
1452            registry,
1453            "action:shrink-pane-width",
1454            "Vim's `<C-w><`: shrink the active pane horizontally.",
1455            AppEffect::ShrinkPaneWidth,
1456        ),
1457        // Issue #29 (2026-05-22): tab management.
1458        // Issue #32 (2026-05-22): picker open-target overrides.
1459        picker_accept_in_split: register_simple(
1460            registry,
1461            "action:picker-accept-in-split",
1462            "Picker `<C-s>`: accept candidate in a horizontal split.",
1463            AppEffect::PickerAcceptInSplit,
1464        ),
1465        picker_accept_in_vsplit: register_simple(
1466            registry,
1467            "action:picker-accept-in-vsplit",
1468            "Picker `<C-v>`: accept candidate in a vertical split.",
1469            AppEffect::PickerAcceptInVSplit,
1470        ),
1471        picker_accept_in_tab: register_simple(
1472            registry,
1473            "action:picker-accept-in-tab",
1474            "Picker `<C-t>`: accept candidate in a new tab.",
1475            AppEffect::PickerAcceptInTab,
1476        ),
1477        next_tab: register_simple(
1478            registry,
1479            "action:next-tab",
1480            "Vim's `gt`: switch to the next tab.",
1481            AppEffect::NextTab,
1482        ),
1483        prev_tab: register_simple(
1484            registry,
1485            "action:prev-tab",
1486            "Vim's `gT`: switch to the previous tab.",
1487            AppEffect::PrevTab,
1488        ),
1489        new_tab: register_simple(
1490            registry,
1491            "action:new-tab",
1492            "`:tabnew`: open a new tab.",
1493            AppEffect::NewTab,
1494        ),
1495        close_tab: register_simple(
1496            registry,
1497            "action:close-tab",
1498            "`:tabclose`: close the active tab.",
1499            AppEffect::CloseTab,
1500        ),
1501        only_tab: register_simple(
1502            registry,
1503            "action:only-tab",
1504            "`:tabonly`: close every tab except the active one.",
1505            AppEffect::OnlyTab,
1506        ),
1507        quit: register_simple(
1508            registry,
1509            "action:quit",
1510            "Graceful editor shutdown.",
1511            AppEffect::Quit,
1512        ),
1513        cancel: register_simple(
1514            registry,
1515            "action:cancel",
1516            "`<C-c>` (`<C-g>` with emacs-keys): cancel the in-flight \
1517             foreground operation and return to Normal mode.",
1518            AppEffect::Cancel,
1519        ),
1520        // move-tab carries no fixed target; the dispatch path
1521        // surfaces it via the count-aware `Action::MoveTab(n)`
1522        // payload. Registered with MoveTab(0) (= move to last)
1523        // as a sensible no-arg default.
1524        move_tab: register_simple(
1525            registry,
1526            "action:move-tab",
1527            "`:tabmove [N]`: move the active tab to position N (default last).",
1528            AppEffect::MoveTab(0),
1529        ),
1530        move_pane_to_new_tab: register_simple(
1531            registry,
1532            "action:move-pane-to-new-tab",
1533            "`<C-w>T`: move the active pane to a fresh tab.",
1534            AppEffect::MovePaneToNewTab,
1535        ),
1536        completion_next: register_simple(
1537            registry,
1538            "action:completion-next",
1539            "Completion-popup `<C-n>` / `<Down>`: focus the next entry.",
1540            AppEffect::CompletionNext,
1541        ),
1542        completion_prev: register_simple(
1543            registry,
1544            "action:completion-prev",
1545            "Completion-popup `<C-p>` / `<Up>`: focus the previous entry.",
1546            AppEffect::CompletionPrev,
1547        ),
1548        completion_accept: register_simple(
1549            registry,
1550            "action:completion-accept",
1551            "Completion-popup `<C-y>` / `<Tab>` / `<CR>`: accept the focused candidate.",
1552            AppEffect::CompletionAccept,
1553        ),
1554        completion_cancel: register_simple(
1555            registry,
1556            "action:completion-cancel",
1557            "Completion-popup `<C-e>`: cancel the popup, stay in Insert.",
1558            AppEffect::CompletionCancel,
1559        ),
1560        completion_cancel_and_exit_insert: register_simple(
1561            registry,
1562            "action:completion-cancel-and-exit-insert",
1563            "Completion-popup `<Esc>`: cancel the popup and exit Insert.",
1564            AppEffect::CompletionCancelAndExitInsert,
1565        ),
1566        completion_toggle_docs: register_simple(
1567            registry,
1568            "action:completion-toggle-docs",
1569            "Completion-popup `<C-d>`: toggle the doc popup.",
1570            AppEffect::CompletionToggleDocs,
1571        ),
1572        completion_docs_scroll_down: register_simple(
1573            registry,
1574            "action:completion-docs-scroll-down",
1575            "Completion-popup `<C-f>`: scroll the doc popup down.",
1576            AppEffect::CompletionDocsScrollDown,
1577        ),
1578        completion_docs_scroll_up: register_simple(
1579            registry,
1580            "action:completion-docs-scroll-up",
1581            "Completion-popup `<C-b>`: scroll the doc popup up.",
1582            AppEffect::CompletionDocsScrollUp,
1583        ),
1584        completion_accept_then_insert: register_action(
1585            registry,
1586            "action:completion-accept-then-insert",
1587            "Completion-popup bare-printable wildcard: accept then insert the captured char (control chars filtered).",
1588            captured_char_action(|c| {
1589                // Mirror the legacy popup's `if !c.is_control()`
1590                // filter so synthetic control chars don't leak
1591                // into the inserted text via the trie's
1592                // CharLiteral wildcard.
1593                if c.is_control() {
1594                    None
1595                } else {
1596                    Some(AppEffect::CompletionAcceptThenInsert(c))
1597                }
1598            }),
1599        ),
1600        insert_register: register_action(
1601            registry,
1602            "action:insert-register",
1603            "Insert a register's contents at the cursor (vim's `<C-r>{reg}`).",
1604            captured_char_action(|c| Some(AppEffect::InsertRegister(c))),
1605        ),
1606        open_yank_picker: register_simple(
1607            registry,
1608            "action:open-yank-picker",
1609            "Open the yank-ring picker, inserting the pick into whichever surface it was opened from.",
1610            AppEffect::OpenYankPicker,
1611        ),
1612        open_arg_picker: register_simple(
1613            registry,
1614            "action:open-arg-picker",
1615            "Open the picker registered for the `:`-line argument under the cursor (vim's omni-completion chord).",
1616            AppEffect::OpenArgPicker,
1617        ),
1618        completion_filter_to_source: register_action(
1619            registry,
1620            "action:completion-filter-to-source",
1621            "Completion-popup filter chord: restrict candidates to a single source (Args::String = source-id).",
1622            captured_string_action(|id| {
1623                if id.is_empty() {
1624                    None
1625                } else {
1626                    Some(AppEffect::CompletionFilterToSource(id))
1627                }
1628            }),
1629        ),
1630        completion_filter_clear: register_simple(
1631            registry,
1632            "action:completion-filter-clear",
1633            "Completion-popup `<C-Space>`: clear the active source filter.",
1634            AppEffect::CompletionFilterClear,
1635        ),
1636        // CR.6: `action:diff-get`/`-put` registered by lattice_diff::install().
1637        snippet_next_placeholder: register_simple(
1638            registry,
1639            "action:snippet-next-placeholder",
1640            "Active-snippet `<Tab>`: jump to the next placeholder.",
1641            AppEffect::SnippetNextPlaceholder,
1642        ),
1643        snippet_prev_placeholder: register_simple(
1644            registry,
1645            "action:snippet-prev-placeholder",
1646            "Active-snippet `<S-Tab>`: jump to the previous placeholder.",
1647            AppEffect::SnippetPrevPlaceholder,
1648        ),
1649        // SN.3c.2 (2026-06-14): the CommandSpec stays (the
1650        // `active-snippet-mode` chord binds it + the mode's
1651        // per-buffer `ActionHandlerRegistry` handler keys on it), but
1652        // its `apply` is now a dead `Effect::None`: the handler always
1653        // intercepts before the grammar Action gate, so this body
1654        // never runs. Same shape as `snippet_expand` (SN.3c.1).
1655        snippet_leave: registry.register_action(
1656            "action:snippet-leave",
1657            "Active-snippet `<Esc>`: exit the snippet (mode-owned; \
1658             `active-snippet-mode`'s handler clears the session + enters Normal).",
1659            ActionSpec {
1660                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
1661                args_schema: vec![],
1662            },
1663        ),
1664
1665        search_refresh: register_simple(
1666            registry,
1667            "action:search-refresh",
1668            "project-search-mode `gr`: re-run the scan with the view's current query.",
1669            AppEffect::SearchRefresh,
1670        ),
1671        compilation_recompile: register_simple(
1672            registry,
1673            "action:compilation-recompile",
1674            "compilation-mode `gr`: recompile — re-run the last compilation command.",
1675            AppEffect::CompileRun { cmdline: None },
1676        ),
1677        // CM.3b: `<CR>` jump-to-source. The CommandSpec stays (the
1678        // `compilation-mode` chord binds it + the mode's per-buffer
1679        // `ActionHandlerRegistry` handler keys on it), but its `apply`
1680        // is a dead `Effect::None`: the handler always intercepts in
1681        // `dispatch` before the grammar Action gate, parsing the cursor
1682        // line and emitting `AppEffect::CompileJumpToLocation`. Same
1683        // shape as `snippet_leave` / `search_jump_to_source`.
1684        compilation_jump: registry.register_action(
1685            "action:compilation-jump",
1686            "compilation-mode `<CR>`: jump to the source location under the cursor \
1687             (mode-owned; the handler parses the line + syncs the error list index).",
1688            ActionSpec {
1689                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
1690                args_schema: vec![],
1691            },
1692        ),
1693        multibuffer_jump_to_source: registry.register_action(
1694            "action:multibuffer-jump-to-source",
1695            "multibuffer-mode `<CR>`: jump to the source file/row of the excerpt under cursor \
1696             (generic; the handler resolves source + path through the multibuffer view).",
1697            ActionSpec {
1698                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
1699                args_schema: vec![],
1700            },
1701        ),
1702    }
1703}
1704
1705/// Helper for the 8 operator-prefix actions (slice 8.i.4.c).
1706/// Captures the `OperatorId` in the closure so each registration
1707/// returns a constant `Effect::AppAction(AppEffect::AbsorbOperatorPrefix(op))`
1708/// for its specific operator.
1709fn register_operator_prefix(
1710    registry: &mut CommandRegistry,
1711    name: &str,
1712    doc: &str,
1713    op: OperatorId,
1714) -> CommandId {
1715    registry.register_action(
1716        name,
1717        doc,
1718        ActionSpec {
1719            apply: Arc::new(move |_| Ok(Effect::AppAction(AppEffect::AbsorbOperatorPrefix(op)))),
1720            args_schema: vec![],
1721        },
1722    )
1723}
1724
1725/// Helper for captured-char wildcard actions (`m<X>`, `'<X>`,
1726/// `\"<X>`, `q<X>`, `@<X>`, Replace mode's wildcard).
1727///
1728/// The dispatcher folds the captured char into the bound
1729/// `CommandInvocation`'s `args` as `Args::Char(c)`. This helper
1730/// reads it back, hands it to `decide`, and emits either the
1731/// chosen `AppEffect` or a no-op when the char doesn't validate.
1732/// `Effect::None` is the no-op signal: `App::apply` clears any
1733/// in-flight pending state on every Invoke (the pending field
1734/// only survives `Action::SetPending(_)`), so a no-op naturally
1735/// drops the pending half-chord without an explicit
1736/// `SetPending(None)` round-trip.
1737fn captured_char_action(
1738    decide: impl Fn(char) -> Option<AppEffect> + Send + Sync + 'static,
1739) -> ActionSpec {
1740    ActionSpec {
1741        apply: Arc::new(move |ctx| {
1742            let c = match ctx.args {
1743                Args::Char(c) => c,
1744                _ => return Ok(Effect::None),
1745            };
1746            Ok(match decide(c) {
1747                Some(eff) => Effect::AppAction(eff),
1748                None => Effect::None,
1749            })
1750        }),
1751        args_schema: vec![],
1752    }
1753}
1754
1755/// Helper for captured-string actions (CSM.K2 filter chords).
1756/// Reads `Args::String(s)` and hands the owned `String` to
1757/// `decide`. Behaves identically to `captured_char_action`
1758/// otherwise -- mismatched args / `None` decisions are
1759/// `Effect::None` so any in-flight pending state is dropped.
1760fn captured_string_action(
1761    decide: impl Fn(String) -> Option<AppEffect> + Send + Sync + 'static,
1762) -> ActionSpec {
1763    ActionSpec {
1764        apply: Arc::new(move |ctx| {
1765            let s = match &ctx.args {
1766                Args::String(s) => s.clone(),
1767                _ => return Ok(Effect::None),
1768            };
1769            Ok(match decide(s) {
1770                Some(eff) => Effect::AppAction(eff),
1771                None => Effect::None,
1772            })
1773        }),
1774        args_schema: vec![],
1775    }
1776}
1777
1778/// Tiny adapter so the pattern-rich registrations above can keep
1779/// the same call-site shape as `register_simple` but pass an
1780/// already-built `ActionSpec`. Mirrors `register_simple`'s wrap
1781/// of `CommandRegistry::register_action`.
1782fn register_action(
1783    registry: &mut CommandRegistry,
1784    name: &str,
1785    doc: &str,
1786    spec: ActionSpec,
1787) -> CommandId {
1788    registry.register_action(name, doc, spec)
1789}
1790
1791/// Helper for the common case: an action whose `apply` is the
1792/// constant `Effect::AppAction(AppEffect::Foo)`. Most slice 8.i
1793/// promotions look like this. Variants that need to inspect
1794/// args / count / register at dispatch time call
1795/// `register_action` directly.
1796fn register_simple(
1797    registry: &mut CommandRegistry,
1798    name: &str,
1799    doc: &str,
1800    effect: AppEffect,
1801) -> CommandId {
1802    registry.register_action(
1803        name,
1804        doc,
1805        ActionSpec {
1806            apply: Arc::new(move |_ctx| Ok(lattice_grammar::Effect::AppAction(effect.clone()))),
1807            args_schema: vec![],
1808        },
1809    )
1810}
1811
1812// CR.6 (2026-06-24): `register_mode_owned` removed — the diff conflict
1813// action shells it served are now registered in `lattice_diff::install()`
1814// (the "modes register commands" pattern).
1815
1816#[cfg(test)]
1817mod tests {
1818    #![allow(clippy::unwrap_used, clippy::panic)]
1819    use super::*;
1820    use lattice_grammar::CancellationToken;
1821    use lattice_grammar::CommandInvocation;
1822    use lattice_grammar::Effect;
1823    use lattice_grammar::dispatcher::execute;
1824
1825    #[test]
1826    fn populate_registers_every_field_into_registry() {
1827        let mut registry = CommandRegistry::new();
1828        let builtins = lattice_grammar::builtins::populate(&mut registry);
1829        let ids = populate(&mut registry, &builtins);
1830        // Every field should round-trip back to a registered
1831        // `CommandKind::Action` entry that names the dashed form.
1832        for (id, expected_name) in [
1833            (ids.match_bracket, "action:match-bracket"),
1834            (ids.toggle_case_at_cursor, "action:toggle-case-at-cursor"),
1835            (ids.open_line_below, "action:open-line-below"),
1836            (ids.open_line_above, "action:open-line-above"),
1837            (ids.lsp_hover_request, "action:lsp-hover"),
1838            (ids.search_next, "action:search-next"),
1839            (ids.search_previous, "action:search-previous"),
1840            (ids.error_next, "action:error-next"),
1841            (ids.error_prev, "action:error-prev"),
1842            (ids.error_first, "action:error-first"),
1843            (ids.error_last, "action:error-last"),
1844            (ids.error_next_file, "action:error-next-file"),
1845            (ids.error_prev_file, "action:error-prev-file"),
1846            (ids.jump_history_back, "action:jump-history-back"),
1847            (ids.jump_history_forward, "action:jump-history-forward"),
1848            (ids.walk_mark_history_back, "action:walk-mark-history-back"),
1849            (
1850                ids.walk_mark_history_forward,
1851                "action:walk-mark-history-forward",
1852            ),
1853            (ids.tag_stack_pop, "action:tag-stack-pop"),
1854            (ids.open_fold_at_cursor, "action:open-fold-at-cursor"),
1855            (ids.close_fold_at_cursor, "action:close-fold-at-cursor"),
1856            (ids.toggle_fold_at_cursor, "action:toggle-fold-at-cursor"),
1857            (ids.open_all_folds, "action:open-all-folds"),
1858            (ids.close_all_folds, "action:close-all-folds"),
1859            (ids.cycle_fold_at_cursor, "action:fold-cycle"),
1860            (ids.cycle_folds_global, "action:fold-cycle-global"),
1861            (ids.goto_parent_fold, "action:fold-goto-parent"),
1862            (ids.delete_fold_at_cursor, "action:delete-fold-at-cursor"),
1863            (ids.open_folds_recursively, "action:open-folds-recursively"),
1864            (
1865                ids.close_folds_recursively,
1866                "action:close-folds-recursively",
1867            ),
1868            (
1869                ids.delete_folds_recursively,
1870                "action:delete-folds-recursively",
1871            ),
1872            (ids.goto_next_fold, "action:goto-next-fold"),
1873            (ids.goto_prev_fold, "action:goto-prev-fold"),
1874            (ids.toggle_fold_enable, "action:toggle-fold-enable"),
1875            (ids.undo, "action:undo"),
1876            (ids.redo, "action:redo"),
1877            (ids.repeat_last_change, "action:repeat-last-change"),
1878            (ids.page_down, "action:page-down"),
1879            (ids.half_page_down, "action:half-page-down"),
1880            (ids.half_page_up, "action:half-page-up"),
1881            (ids.page_up, "action:page-up"),
1882            (ids.scroll_line_up, "action:scroll-line-up"),
1883            (ids.scroll_line_down, "action:scroll-line-down"),
1884            (ids.redraw_screen, "action:redraw-screen"),
1885            (ids.open_command_picker, "action:open-command-picker"),
1886            (ids.open_history_picker, "action:open-history-picker"),
1887            (
1888                ids.open_search_history_picker,
1889                "action:open-search-history-picker",
1890            ),
1891            (ids.enter_command_line, "action:enter-command-line"),
1892            (ids.command_line_submit, "action:command-line-submit"),
1893            (ids.command_line_cancel, "action:command-line-cancel"),
1894            (
1895                ids.command_line_history_prev,
1896                "action:command-line-history-prev",
1897            ),
1898            (
1899                ids.command_line_history_next,
1900                "action:command-line-history-next",
1901            ),
1902            (ids.command_line_complete, "action:command-line-complete"),
1903            (
1904                ids.command_line_complete_prev,
1905                "action:command-line-complete-prev",
1906            ),
1907            (
1908                ids.command_line_describe_under_cursor,
1909                "action:command-line-describe-under-cursor",
1910            ),
1911            (
1912                ids.command_line_toggle_expand,
1913                "action:command-line-toggle-expand",
1914            ),
1915            (ids.search_line_submit, "action:search-line-submit"),
1916            (ids.search_line_cancel, "action:search-line-cancel"),
1917            (ids.search_line_backspace, "action:search-line-backspace"),
1918            (
1919                ids.search_line_history_prev,
1920                "action:search-line-history-prev",
1921            ),
1922            (
1923                ids.search_line_history_next,
1924                "action:search-line-history-next",
1925            ),
1926            (
1927                ids.search_line_toggle_expand,
1928                "action:search-line-toggle-expand",
1929            ),
1930            (ids.prompt_line_submit, "action:prompt-line-submit"),
1931            (ids.prompt_line_cancel, "action:prompt-line-cancel"),
1932            (ids.oil_navigate_up, "action:oil-navigate-up"),
1933            (ids.reselect_last_visual, "action:reselect-last-visual"),
1934            (ids.paste_after, "action:paste-after"),
1935            (ids.paste_before, "action:paste-before"),
1936            (ids.lsp_definition_request, "action:lsp-definition"),
1937            (ids.lsp_declaration_request, "action:lsp-declaration"),
1938            (
1939                ids.lsp_type_definition_request,
1940                "action:lsp-type-definition",
1941            ),
1942            (ids.lsp_implementation_request, "action:lsp-implementation"),
1943            (ids.lsp_references_request, "action:lsp-references"),
1944            (ids.lsp_follow_link_at_cursor, "action:lsp-follow-link"),
1945            (ids.enter_append, "action:enter-append"),
1946            (
1947                ids.enter_insert_first_non_blank,
1948                "action:enter-insert-first-non-blank",
1949            ),
1950            (
1951                ids.enter_append_end_of_line,
1952                "action:enter-append-end-of-line",
1953            ),
1954            (ids.display_line_down, "action:display-line-down"),
1955            (ids.display_line_up, "action:display-line-up"),
1956            (ids.display_line_start, "action:display-line-start"),
1957            (ids.display_line_end, "action:display-line-end"),
1958            (
1959                ids.create_fold_from_visual,
1960                "action:create-fold-from-visual",
1961            ),
1962            (ids.delete_char_backward, "action:delete-char-backward"),
1963            (ids.completion_trigger, "action:completion-trigger"),
1964            (ids.snippet_expand, "action:snippet-expand"),
1965            (ids.lsp_diagnostic_popup, "action:lsp-diagnostic-popup"),
1966            (ids.exit_visual, "action:exit-visual"),
1967            (ids.swap_visual_ends, "action:swap-visual-ends"),
1968            (ids.replace_undo_last, "action:replace-undo-last"),
1969            (ids.enter_mode_insert, "action:enter-mode-insert"),
1970            (ids.enter_mode_normal, "action:enter-mode-normal"),
1971            (ids.enter_mode_replace, "action:enter-mode-replace"),
1972            (ids.enter_visual_charwise, "action:enter-visual-charwise"),
1973            (ids.enter_visual_linewise, "action:enter-visual-linewise"),
1974            (ids.enter_visual_blockwise, "action:enter-visual-blockwise"),
1975            (ids.enter_search_forward, "action:enter-search-forward"),
1976            (ids.enter_search_backward, "action:enter-search-backward"),
1977            (
1978                ids.search_word_under_cursor_forward,
1979                "action:search-word-under-cursor-forward",
1980            ),
1981            (
1982                ids.search_word_under_cursor_backward,
1983                "action:search-word-under-cursor-backward",
1984            ),
1985            (ids.jump_viewport_top, "action:jump-viewport-top"),
1986            (ids.jump_viewport_middle, "action:jump-viewport-middle"),
1987            (ids.jump_viewport_bottom, "action:jump-viewport-bottom"),
1988            (ids.scroll_cursor_to_top, "action:scroll-cursor-to-top"),
1989            (
1990                ids.scroll_cursor_to_center,
1991                "action:scroll-cursor-to-center",
1992            ),
1993            (
1994                ids.scroll_cursor_to_bottom,
1995                "action:scroll-cursor-to-bottom",
1996            ),
1997            (ids.join_lines_with_space, "action:join-lines-with-space"),
1998            (ids.join_lines_bare, "action:join-lines-bare"),
1999            (ids.find_repeat_forward, "action:find-repeat-forward"),
2000            (ids.find_repeat_reverse, "action:find-repeat-reverse"),
2001            (ids.insert_newline, "action:insert-newline"),
2002            (ids.insert_tab, "action:insert-tab"),
2003            (ids.overwrite_char, "action:overwrite-char"),
2004            (ids.set_mark, "action:set-mark"),
2005            (ids.jump_to_mark_line, "action:jump-to-mark-line"),
2006            (ids.jump_to_mark_exact, "action:jump-to-mark-exact"),
2007            (ids.select_register, "action:select-register"),
2008            (ids.start_macro_record, "action:start-macro-record"),
2009            (ids.play_macro, "action:play-macro"),
2010            (ids.absorb_operator_delete, "action:absorb-operator-delete"),
2011            (ids.absorb_operator_change, "action:absorb-operator-change"),
2012            (ids.absorb_operator_yank, "action:absorb-operator-yank"),
2013            (
2014                ids.absorb_operator_indent_right,
2015                "action:absorb-operator-indent-right",
2016            ),
2017            (
2018                ids.absorb_operator_indent_left,
2019                "action:absorb-operator-indent-left",
2020            ),
2021            (
2022                ids.absorb_operator_reindent,
2023                "action:absorb-operator-reindent",
2024            ),
2025            (ids.absorb_operator_reflow, "action:absorb-operator-reflow"),
2026            (ids.absorb_operator_upper, "action:absorb-operator-upper"),
2027            (ids.absorb_operator_lower, "action:absorb-operator-lower"),
2028            (
2029                ids.absorb_operator_toggle_case,
2030                "action:absorb-operator-toggle-case",
2031            ),
2032            (ids.split_pane_horizontal, "action:split-pane-horizontal"),
2033            (ids.split_pane_vertical, "action:split-pane-vertical"),
2034            (ids.close_pane, "action:close-pane"),
2035            (ids.only_pane, "action:only-pane"),
2036            (ids.toggle_zoom_pane, "action:toggle-zoom-pane"),
2037            (ids.navigate_pane_left, "action:navigate-pane-left"),
2038            (ids.navigate_pane_down, "action:navigate-pane-down"),
2039            (ids.navigate_pane_up, "action:navigate-pane-up"),
2040            (ids.navigate_pane_right, "action:navigate-pane-right"),
2041            (ids.next_pane, "action:next-pane"),
2042            (ids.prev_pane, "action:prev-pane"),
2043            // Issue #28 (2026-05-22): split-ratio adjustment.
2044            (ids.equalize_panes, "action:equalize-panes"),
2045            (ids.grow_pane_height, "action:grow-pane-height"),
2046            (ids.shrink_pane_height, "action:shrink-pane-height"),
2047            (ids.grow_pane_width, "action:grow-pane-width"),
2048            (ids.shrink_pane_width, "action:shrink-pane-width"),
2049            // Issue #29 (2026-05-22): tab management.
2050            // Issue #32 (2026-05-22): picker open-target overrides.
2051            (ids.picker_accept_in_split, "action:picker-accept-in-split"),
2052            (
2053                ids.picker_accept_in_vsplit,
2054                "action:picker-accept-in-vsplit",
2055            ),
2056            (ids.picker_accept_in_tab, "action:picker-accept-in-tab"),
2057            (ids.next_tab, "action:next-tab"),
2058            (ids.prev_tab, "action:prev-tab"),
2059            (ids.new_tab, "action:new-tab"),
2060            (ids.close_tab, "action:close-tab"),
2061            (ids.only_tab, "action:only-tab"),
2062            (ids.move_tab, "action:move-tab"),
2063            (ids.move_pane_to_new_tab, "action:move-pane-to-new-tab"),
2064            (ids.completion_next, "action:completion-next"),
2065            (ids.completion_prev, "action:completion-prev"),
2066            (ids.completion_accept, "action:completion-accept"),
2067            (ids.completion_cancel, "action:completion-cancel"),
2068            (
2069                ids.completion_cancel_and_exit_insert,
2070                "action:completion-cancel-and-exit-insert",
2071            ),
2072            (ids.completion_toggle_docs, "action:completion-toggle-docs"),
2073            (
2074                ids.completion_docs_scroll_down,
2075                "action:completion-docs-scroll-down",
2076            ),
2077            (
2078                ids.completion_docs_scroll_up,
2079                "action:completion-docs-scroll-up",
2080            ),
2081            (
2082                ids.completion_accept_then_insert,
2083                "action:completion-accept-then-insert",
2084            ),
2085            (ids.insert_register, "action:insert-register"),
2086            (ids.open_yank_picker, "action:open-yank-picker"),
2087            (ids.open_arg_picker, "action:open-arg-picker"),
2088            (
2089                ids.completion_filter_to_source,
2090                "action:completion-filter-to-source",
2091            ),
2092            (
2093                ids.completion_filter_clear,
2094                "action:completion-filter-clear",
2095            ),
2096            (
2097                ids.snippet_next_placeholder,
2098                "action:snippet-next-placeholder",
2099            ),
2100            (
2101                ids.snippet_prev_placeholder,
2102                "action:snippet-prev-placeholder",
2103            ),
2104            (ids.snippet_leave, "action:snippet-leave"),
2105        ] {
2106            let spec = registry
2107                .lookup(id)
2108                .unwrap_or_else(|| panic!("missing registry entry for `{expected_name}`"));
2109            assert_eq!(spec.name, expected_name);
2110        }
2111    }
2112
2113    #[test]
2114    fn dispatch_returns_app_action_effect() {
2115        let mut registry = CommandRegistry::new();
2116        let builtins = lattice_grammar::builtins::populate(&mut registry);
2117        let ids = populate(&mut registry, &builtins);
2118        let mut doc = lattice_core::Document::empty();
2119        let inv = CommandInvocation::of(ids.match_bracket);
2120        let eff = execute(
2121            &registry,
2122            &mut doc,
2123            lattice_core::BufferId(0),
2124            lattice_protocol::position::Position::ZERO,
2125            inv,
2126            &CancellationToken::never(),
2127        )
2128        .unwrap();
2129        match eff {
2130            Effect::AppAction(AppEffect::MatchBracket) => {}
2131            other => panic!("expected MatchBracket, got {other:?}"),
2132        }
2133    }
2134
2135    /// Slice 8.i.3: captured-char `ActionSpec`s validate the
2136    /// captured char and return `Effect::None` on rejection. The
2137    /// dispatcher always passes through; the no-op effect is the
2138    /// signal that the pending half-chord should drop without an
2139    /// explicit `SetPending(None)` round-trip.
2140    #[test]
2141    fn captured_char_specs_validate() {
2142        let mut registry = CommandRegistry::new();
2143        let builtins = lattice_grammar::builtins::populate(&mut registry);
2144        let ids = populate(&mut registry, &builtins);
2145        let mut doc = lattice_core::Document::empty();
2146        let cancel = CancellationToken::never();
2147        let pos = lattice_protocol::position::Position::ZERO;
2148
2149        let dispatch_with_char = |id: lattice_protocol::ids::CommandId,
2150                                  c: char,
2151                                  registry: &CommandRegistry,
2152                                  doc: &mut lattice_core::Document|
2153         -> Effect {
2154            execute(
2155                registry,
2156                doc,
2157                lattice_core::BufferId(0),
2158                pos,
2159                CommandInvocation::of(id).with_args(Args::Char(c)),
2160                &cancel,
2161            )
2162            .unwrap()
2163        };
2164
2165        // Set-mark: alphanumeric pass, punctuation reject.
2166        match dispatch_with_char(ids.set_mark, 'a', &registry, &mut doc) {
2167            Effect::AppAction(AppEffect::SetMark('a')) => {}
2168            other => panic!("expected SetMark('a'), got {other:?}"),
2169        }
2170        match dispatch_with_char(ids.set_mark, '!', &registry, &mut doc) {
2171            Effect::None => {}
2172            other => panic!("expected None for invalid mark name, got {other:?}"),
2173        }
2174
2175        // Select-register: routes through `Register::from_input_char`.
2176        match dispatch_with_char(ids.select_register, '+', &registry, &mut doc) {
2177            Effect::AppAction(AppEffect::SelectRegister(Register::System)) => {}
2178            other => panic!("expected SelectRegister(System), got {other:?}"),
2179        }
2180        match dispatch_with_char(ids.select_register, 'a', &registry, &mut doc) {
2181            Effect::AppAction(AppEffect::SelectRegister(Register::Named('a'))) => {}
2182            other => panic!("expected SelectRegister(Named('a')), got {other:?}"),
2183        }
2184        match dispatch_with_char(ids.select_register, '@', &registry, &mut doc) {
2185            Effect::None => {}
2186            other => panic!("expected None for invalid register char, got {other:?}"),
2187        }
2188
2189        // Play-macro: `@` -> PlayLastMacro; alphanumeric ->
2190        // PlayMacro(c); other -> None.
2191        match dispatch_with_char(ids.play_macro, '@', &registry, &mut doc) {
2192            Effect::AppAction(AppEffect::PlayLastMacro) => {}
2193            other => panic!("expected PlayLastMacro, got {other:?}"),
2194        }
2195        match dispatch_with_char(ids.play_macro, 'q', &registry, &mut doc) {
2196            Effect::AppAction(AppEffect::PlayMacro('q')) => {}
2197            other => panic!("expected PlayMacro('q'), got {other:?}"),
2198        }
2199        match dispatch_with_char(ids.play_macro, '!', &registry, &mut doc) {
2200            Effect::None => {}
2201            other => panic!("expected None for invalid macro key, got {other:?}"),
2202        }
2203
2204        // Overwrite-char: any char passes.
2205        match dispatch_with_char(ids.overwrite_char, '$', &registry, &mut doc) {
2206            Effect::AppAction(AppEffect::OverwriteChar('$')) => {}
2207            other => panic!("expected OverwriteChar('$'), got {other:?}"),
2208        }
2209    }
2210}