Skip to main content

lattice_grammar/
app_effect.rs

1//! `AppEffect` -- the typed App-side effects produced by free-form
2//! `CommandKind::Action` registrations.
3//!
4//! Background: the keymap-bound `Action` enum in `lattice-ui-tui`
5//! historically encoded the App's response to non-grammar chords
6//! (`<Esc>`, `o`, `<C-w>v`, ...). Slice 8.i (see
7//! [`docs/dev/notes/8i-approach.md`](../../../docs/dev/notes/8i-approach.md)) retires
8//! the per-binding `Action` bridge and routes those chords through
9//! the unified dispatcher's `CommandKind::Action` branch instead.
10//! Action-kind registry entries return `Effect::AppAction(AppEffect)`;
11//! the App's `apply_effect` then matches on the inner `AppEffect`
12//! variant.
13//!
14//! Why a sibling type to `Effect` rather than a flat extension:
15//!
16//! - `Effect`'s existing variants describe *core / dispatcher-native*
17//!   work (`Edits`, `SelectionChange`, `Yank`, `EnterMode`,
18//!   `Substitute`, ...) and *ex-command-emitted* App work
19//!   (`SaveBuffer`, `OpenBuffer`, `LspRestart`, ...). Both are
20//!   produced by code paths that *resolve* a typed grammar concern
21//!   into a typed effect.
22//! - `AppEffect` is for chord bindings that historically had no
23//!   grammar concept attached -- `<Esc>` exits Visual, `<C-w>v`
24//!   splits a pane, `o` opens a line below. Treating those as
25//!   first-class "free-form actions" keeps the dispatcher contract
26//!   ("everything returns Effect") honest without fusing two
27//!   conceptually different surfaces into one giant enum.
28//!
29//! Applied host-side by `Editor::apply_app_effect` (reached from the
30//! [`crate::Effect::AppAction`] arm of `handle_effect`), so every variant
31//! acts on the focused buffer / active pane at apply time unless it names a
32//! buffer. Several variants are now *fallback shells*: the chord is owned by
33//! a mode whose `ActionHandlerRegistry` closure intercepts it before the
34//! arm runs, and the arm is a no-op kept so the registered action id
35//! resolves. Those variants say so.
36
37use serde::{Deserialize, Serialize};
38
39use crate::modal::{ModalState, SearchDirection, VisualKind};
40use crate::register::Register;
41use crate::registry::OperatorId;
42
43// M.4 follow-up: `PaneDirection` moved to `lattice-core::ui::pane`
44// (the same crate as the pane geometry). lattice-grammar
45// re-exports it so `AppEffect::NavigatePane(PaneDirection)`
46// remains ergonomic.
47pub use lattice_core::ui::pane::PaneDirection;
48
49/// Vim's `H` / `M` / `L` target positions: where in the visible
50/// viewport the cursor lands. App-side concept hosted here so
51/// `AppEffect::JumpViewport(ViewportPos)` can carry the typed
52/// payload without `lattice-ui-tui` having to dance through a
53/// dependency cycle. Slice 8.i.2.c hoist; the App's previous
54/// `crate::app::ViewportPos` becomes a `pub use` re-export of
55/// this type.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum ViewportPos {
58    /// `H` -- first visible line of the viewport.
59    Top,
60    /// `M` -- middle visible line of the viewport.
61    Middle,
62    /// `L` -- last visible line of the viewport.
63    Bottom,
64}
65
66/// Vim's `zz` / `zt` / `zb` target positions: where in the
67/// viewport the cursor's current line should sit after the
68/// scroll. App-side concept hosted alongside [`ViewportPos`] for
69/// the same dependency reason. Slice 8.i.2.c hoist.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum ScrollPos {
72    /// `zt` -- cursor's line lands at the top of the viewport.
73    Top,
74    /// `zz` -- cursor's line lands at the vertical centre.
75    Center,
76    /// `zb` -- cursor's line lands at the bottom of the viewport.
77    Bottom,
78}
79
80/// Manual horizontal-scroll commands (HS.2), the vim `z{l,h,L,H,s,e}`
81/// family. Carried by [`AppEffect::HorizontalScroll`]; the host
82/// handler mutates `leftcol` and keeps the cursor inside the new
83/// window. `wrap`-off only (no-op under wrap, like the cursor-follow
84/// clamp). See `docs/dev/architecture/horizontal-scroll.md`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub enum HScroll {
87    /// `zl` / `zh`: scroll `count` columns right / left.
88    Columns {
89        /// `true` = `zl` (view moves right), `false` = `zh`.
90        right: bool,
91    },
92    /// `zL` / `zH`: scroll half the body width right / left.
93    HalfScreen {
94        /// `true` = `zL`, `false` = `zH`.
95        right: bool,
96    },
97    /// `zs` (cursor to left edge) / `ze` (cursor to right edge).
98    CursorToEdge {
99        /// `true` = `ze` (cursor column at the right edge), `false` = `zs`.
100        end: bool,
101    },
102}
103
104/// CM.2 (2026-07-22): which error entry a [`AppEffect::ErrorNav`]
105/// should resolve to. Carried in the AppEffect so the whole
106/// `:cnext` / `:cprev` / `:cc` / `:cfirst` / `:clast` / `]q` / `[q`
107/// family shares a single host handler (`Editor::do_error_nav`).
108/// The error list is core substrate (like the jump ring), so a
109/// host AppEffect variant is the right carrier — not a
110/// provider-specific one.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub enum ErrorTarget {
113    /// `:cnext` / `]q` — next entry (wraps to first past the end).
114    Next,
115    /// `:cprev` / `[q` — previous entry (wraps to last past the start).
116    Prev,
117    /// `:cc [N]` — jump to the Nth entry (1-based). `None` (bare
118    /// `:cc`) re-visits the current entry.
119    Jump(Option<usize>),
120    /// `:cfirst` / `[Q` — jump to the first entry.
121    First,
122    /// `:clast` / `]Q` — jump to the last entry.
123    Last,
124    /// `:cnextfile` / `]qf` — first entry of the next file (wraps).
125    NextFile,
126    /// `:cprevfile` / `[qf` — first entry of the previous file (wraps).
127    PrevFile,
128}
129
130/// App-side typed effect produced by a `CommandKind::Action`
131/// dispatch (DESIGN.md §5.2.1, see also `docs/dev/notes/8i-approach.md`).
132///
133/// Insert-mode line-editing operations — the general readline/vim chords
134/// available in **every** buffer (part of the built-in grammar keymap, not a
135/// mode). Distinct from Normal-mode motions/operators because Insert cursor
136/// semantics differ (the caret sits *between* bytes and may rest past the last
137/// char), so `<C-e>` lands after the last byte where `$` would land on it.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139pub enum InsertLineEdit {
140    /// `<C-a>`: caret to the start of the line (byte 0).
141    CursorLineStart,
142    /// `<C-e>`: caret to the end of the line (past the last byte).
143    CursorLineEnd,
144    /// `<C-b>`: caret one byte left (stops at line start).
145    CursorCharLeft,
146    /// `<C-f>`: caret one byte right (stops past the last byte).
147    CursorCharRight,
148    /// `<C-w>`: delete the word before the caret.
149    DeleteWordBackward,
150    /// `<C-u>`: delete from the line start to the caret.
151    DeleteToLineStart,
152    /// `<C-k>`: delete from the caret to the line end.
153    KillToLineEnd,
154    /// `<C-t>`: indent the current line by one shiftwidth.
155    IndentLine,
156    /// `<C-d>`: dedent the current line by one shiftwidth.
157    DedentLine,
158}
159
160/// Variants are added incrementally during slice 8.i as each
161/// historical `Action` variant is promoted from the legacy
162/// `bind_legacy` bridge to a typed `CommandInvocation`.
163///
164/// **`Eq` was dropped in PV.1 (2026-08-12)**, deliberately.
165/// [`Self::OpenProviderView`] carries an [`Args`](crate::args::Args) so a
166/// provider's trigger keeps the typed multi-argument shape the `:` line
167/// and transient rows already produce — and `Args` reaches
168/// `ArgValue::Invocation(Box<CommandInvocation>)`, which is `PartialEq`
169/// but not `Eq`. The alternative (flattening the payload to a
170/// `Option<String>`) would force every provider to re-encode structured
171/// arguments as strings, which is the stringly-typed weakness the seam
172/// exists to avoid. Nothing keys a map or set on `AppEffect`; `PartialEq`
173/// is what the round-trip tests use.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub enum AppEffect {
176    /// Graceful editor shutdown. The App's `apply_effect` arm
177    /// publishes `Event::BeforeQuit` and sets the `should_quit`
178    /// flag the runtime polls between frames; matches today's
179    /// `Action::Quit` semantics exactly. (8.i.0 smoke variant.)
180    ///
181    /// CM.3d (2026-07-22) unbound the `<C-c>` → quit hatch that
182    /// used to be hardcoded in `input.rs`; `action:quit` is now
183    /// reachable only through `:q` and user bindings.
184    Quit,
185    /// CG.1: **foreground cancellation**. Flips the token of the
186    /// in-flight user-initiated async op (search scan, LSP command,
187    /// plugin call) and snaps the editor back to a stable Normal
188    /// state. Bound to `<C-c>` at the Builtin layer in every mode,
189    /// and to `<C-g>` by `emacs-keys-mode`.
190    ///
191    /// Idempotent: with no op armed it degrades to the mode reset
192    /// alone, which is why it is safe as a universal binding.
193    /// See `docs/dev/architecture/cancellation.md`.
194    Cancel,
195    /// RF.5b: an operator resolved its range and the buffer's chain for
196    /// that intent says a **non-native** provider owns it.
197    ///
198    /// The operator cannot run one itself — an LSP round-trip and a
199    /// process spawn are both asynchronous, and the grammar layer has
200    /// neither client nor runtime. So it returns the range and the host
201    /// resolves the chain, exactly as `SearchTrigger` hands back a query
202    /// rather than running a search.
203    ///
204    /// Emitted only when the winning rung is not `native`, so the
205    /// default configuration never produces one and the common path is
206    /// unchanged.
207    FormatRange {
208        /// Which kind of formatting the operator asked for (`=` indent,
209        /// `gq` reflow, `g=` reformat); selects the provider chain.
210        intent: lattice_core::FormatIntent,
211        /// Inclusive 0-based line span.
212        start_line: u32,
213        /// Last line of the span (inclusive, 0-based).
214        end_line: u32,
215    },
216    /// Vim's `%`. Jumps to the bracket / brace / paren matching
217    /// the one at-or-after the cursor on the current line.
218    /// Promoted from `Action::MatchBracket` in slice 8.i.1.a.
219    MatchBracket,
220    /// Vim's `~`. Toggles the case of the char at the cursor and
221    /// advances by one byte. Promoted from
222    /// `Action::ToggleCaseAtCursor` in slice 8.i.1.a.
223    ToggleCaseAtCursor,
224    /// Vim's `o`. Opens a new line below the current line and
225    /// enters Insert. Promoted from `Action::OpenLineBelow` in
226    /// slice 8.i.1.a.
227    OpenLineBelow,
228    /// Vim's `O`. Opens a new line above the current line and
229    /// enters Insert. Promoted from `Action::OpenLineAbove` in
230    /// slice 8.i.1.a.
231    OpenLineAbove,
232    // L7: `AppEffect::LspHoverRequest` removed — `K` is mode-owned now
233    // (`lsp-mode`'s `action_handlers()` emits `Effect::Lsp(LspRequest::Hover)`).
234    /// Vim's `n`. Re-runs the last search forward. Promoted from
235    /// `Action::SearchNext` in slice 8.i.1.b.
236    SearchNext,
237    /// Vim's `N`. Re-runs the last search in the reverse
238    /// direction. Promoted from `Action::SearchPrevious` in
239    /// slice 8.i.1.b.
240    SearchPrevious,
241    /// Vim's `<C-o>`. Walk one step backward through the position
242    /// history (DESIGN.md §5.1.1). Promoted from
243    /// `Action::JumpHistoryBack` in slice 8.i.1.b.
244    JumpHistoryBack,
245    /// Vim's `<C-i>` / `<Tab>`. Walk one step forward through the
246    /// position history. Promoted from `Action::JumpHistoryForward`
247    /// in slice 8.i.1.b.
248    JumpHistoryForward,
249    /// PBH.3: `<C-6>`. Walk one step **back** through the *active
250    /// pane's* buffer trail — the buffers this pane has shown.
251    ///
252    /// Distinct from [`Self::JumpHistoryBack`] (`<C-o>`), which walks
253    /// the global position ring at *position* granularity. This one is
254    /// per-pane and moves whole buffers. See
255    /// `docs/dev/architecture/pane-buffer-history.md`.
256    PaneHistoryBack,
257    /// PBH.3: `<C-7>`. Walk one step **forward** through the active
258    /// pane's buffer trail.
259    PaneHistoryForward,
260    /// Vim's `g;`. Walk one step backward through the
261    /// mark history (oldest -> newest cursor positions in this
262    /// buffer). Promoted from `Action::WalkMarkHistoryBack` in
263    /// slice 8.i.1.b.
264    WalkMarkHistoryBack,
265    /// Vim's `g,`. Walk one step forward through the mark
266    /// history. Promoted from `Action::WalkMarkHistoryForward` in
267    /// slice 8.i.1.b.
268    WalkMarkHistoryForward,
269    /// Vim's `<C-t>`. Pop one entry off the tag stack and jump
270    /// back to where the previous `gd` / `<C-]>` originated.
271    /// Promoted from `Action::TagStackPop` in slice 8.i.1.b.
272    TagStackPop,
273    /// Vim's `zo`. Open the fold containing the cursor.
274    /// Promoted from `Action::OpenFoldAtCursor` in slice 8.i.1.c.
275    OpenFoldAtCursor,
276    /// Vim's `zc`. Close the fold containing the cursor.
277    /// Promoted from `Action::CloseFoldAtCursor` in slice 8.i.1.c.
278    CloseFoldAtCursor,
279    /// Vim's `za`. Toggle the fold containing the cursor.
280    /// Promoted from `Action::ToggleFoldAtCursor` in slice 8.i.1.c.
281    ToggleFoldAtCursor,
282    /// Vim's `zR`. Open every fold in the buffer.
283    /// Promoted from `Action::OpenAllFolds` in slice 8.i.1.c.
284    OpenAllFolds,
285    /// Vim's `zM`. Close every fold in the buffer.
286    /// Promoted from `Action::CloseAllFolds` in slice 8.i.1.c.
287    CloseAllFolds,
288    /// org-cycle `z<Space>` / `:fold-cycle`. Cycle the heading/fold under
289    /// the cursor through emacs org-mode's local states
290    /// FOLDED → CHILDREN → SUBTREE.
291    CycleFoldAtCursor,
292    /// org-cycle `z<Tab>` / `:fold-cycle-global`. Cycle the WHOLE buffer
293    /// through OVERVIEW → CONTENTS → SHOW-ALL.
294    CycleFoldsGlobal,
295    /// `zp` / `:fold-goto-parent`. Move the cursor to the parent heading
296    /// (one level up the fold hierarchy) — emacs `outline-up-heading`.
297    GotoParentFold,
298    /// Vim's `zd`. Delete the fold containing the cursor (drop
299    /// it from the manual fold table; structure-driven folds
300    /// reappear on the next reparse). Promoted from
301    /// `Action::DeleteFoldAtCursor` in slice 8.i.1.c.
302    DeleteFoldAtCursor,
303    /// VM.3h: vim's `zO`. Open every fold containing the cursor line and every
304    /// fold nested inside those. In Visual, the same over each selected line.
305    OpenFoldsRecursively,
306    /// VM.3h: vim's `zC`. Close every fold containing the cursor line, and
307    /// nothing nested below the cursor. In Visual, every fold containing a
308    /// selected line, enclosing ones included.
309    CloseFoldsRecursively,
310    /// VM.3h: vim's `zD`. Delete the innermost fold containing the cursor line
311    /// and every fold nested inside it. In Visual, the folds inside the
312    /// selection, not the ones that merely enclose it.
313    DeleteFoldsRecursively,
314    /// Vim's `zj`. Move cursor to the start of the next fold.
315    /// Promoted from `Action::GotoNextFold` in slice 8.i.1.c.
316    GotoNextFold,
317    /// Vim's `zk`. Move cursor to the end of the previous fold.
318    /// Promoted from `Action::GotoPrevFold` in slice 8.i.1.c.
319    GotoPrevFold,
320    /// Vim's `zi`. Toggle the `foldenable` option (when off,
321    /// every line renders flat regardless of any closed flag).
322    /// Promoted from `Action::ToggleFoldEnable` in slice 8.i.1.c.
323    ToggleFoldEnable,
324    /// Vim's `u`. Undo the last buffer change. Promoted from
325    /// `Action::Undo` in slice 8.i.1.d.
326    Undo,
327    /// Vim's `<C-r>`. Redo the last undone change. Promoted from
328    /// `Action::Redo` in slice 8.i.1.d.
329    Redo,
330    /// Vim's `.`. Repeat the last change (operator + motion +
331    /// register + count). Promoted from `Action::RepeatLastChange`
332    /// in slice 8.i.1.d.
333    RepeatLastChange,
334    /// Vim's `<C-f>`. Page-down: scroll the viewport down one
335    /// page. Promoted from `Action::PageDown` in slice 8.i.1.d.
336    PageDown,
337    /// VM.3j-2: vim's `<C-d>` / `<C-u>` — scroll the view and the cursor
338    /// together by `scroll` lines (half the window by default). SCROLL
339    /// commands, not motions: vim composes no operator with them.
340    HalfPageDown,
341    /// Vim's `<C-u>`; the upward twin of [`Self::HalfPageDown`].
342    HalfPageUp,
343    /// Vim's `<C-b>`. Page-up: scroll the viewport up one page.
344    /// Promoted from `Action::PageUp` in slice 8.i.1.d.
345    PageUp,
346    /// Vim's `<C-y>`. Scroll viewport up one line (cursor
347    /// stays at the same screen position when possible).
348    /// Promoted from `Action::ScrollLineUp` in slice 8.i.1.d.
349    ScrollLineUp,
350    /// Vim's `<C-e>`. Scroll viewport down one line. Promoted
351    /// from `Action::ScrollLineDown` in slice 8.i.1.d.
352    ScrollLineDown,
353    /// Vim's `<C-l>`. Force a full screen redraw. Promoted from
354    /// `Action::RedrawScreen` in slice 8.i.1.e.
355    RedrawScreen,
356    /// Vim's `:` / Emacs' `M-x`. Open the command picker over
357    /// all registered ex-commands. If the chosen command has a
358    /// required first argument the picker arms the cmdline so
359    /// the user can supply it; otherwise executes immediately.
360    OpenCommandPicker,
361    /// MB.3: Vim's `q:`. Open the command-line *history* picker
362    /// over `command_history`. `<CR>` loads the chosen command
363    /// into the `:` line WITHOUT executing (the user tweaks /
364    /// `<C-x><C-e>` expands, then `<CR>`s). Fired from an ordinary
365    /// buffer's Normal mode or the expanded tier-2 band's Normal
366    /// mode (where it seeds the picker filter with the in-progress
367    /// command-line text).
368    OpenHistoryPicker,
369    /// MB.5: `q/` / `q?` — open the search-line history picker.
370    /// Accept loads the chosen term into the `/` search line.
371    OpenSearchHistoryPicker,
372    /// Vim's `:`. Enter the command-line minibuffer. Promoted
373    /// from `Action::EnterCommandLine` in slice 8.i.1.e.
374    EnterCommandLine,
375    /// MB.1: `<CR>` in `command-line-mode` — submit (or accept the
376    /// open completion candidate). Drives the rewired
377    /// `Editor::do_command_line_submit`.
378    CommandLineSubmit,
379    /// MB.1: `<Esc>` / `<C-c>` in `command-line-mode` — cancel (or
380    /// dismiss the open completion popup first).
381    CommandLineCancel,
382    /// MB.1: `<C-p>` / `<Up>` in `command-line-mode` — walk history
383    /// backward (or previous completion candidate when the popup is open).
384    CommandLineHistoryPrev,
385    /// MB.1: `<C-n>` / `<Down>` in `command-line-mode` — walk history
386    /// forward (or next completion candidate when the popup is open).
387    CommandLineHistoryNext,
388    /// MB.1: `<Tab>` in `command-line-mode` — open the completion popup
389    /// or advance the selection.
390    CommandLineComplete,
391    /// MB.1: `<S-Tab>` in `command-line-mode` — previous completion
392    /// candidate.
393    CommandLineCompletePrev,
394    /// MB.1: `<C-h>` in `command-line-mode` — describe the command / arg
395    /// under the cursor.
396    CommandLineDescribeUnderCursor,
397    /// MB.2: `<C-x><C-e>` in `command-line-mode` — toggle the `:` line's
398    /// **expanded** tier-2 mini-buffer band (full modal editing in place),
399    /// or collapse it back to the one-row readline line for review.
400    CommandLineToggleExpand,
401    /// MB.5a: `<CR>` on the `/`·`?` search line — submit the search
402    /// pattern. Resolved from `search-line-mode`'s Insert keymap.
403    SearchLineSubmit,
404    /// MB.5a: `<Esc>` / `<C-c>` on the `/`·`?` search line — cancel
405    /// the search and restore the prior editing buffer.
406    SearchLineCancel,
407    /// `<BS>` on the `/`·`?` line: delete a char, or cancel when empty.
408    SearchLineBackspace,
409    /// MB.5b: `<C-p>` / `<Up>` on the `/`·`?` search line — walk to an
410    /// older entry in `search_history`.
411    SearchLineHistoryPrev,
412    /// MB.5b: `<C-n>` / `<Down>` on the `/`·`?` search line — walk to a
413    /// newer entry in `search_history`.
414    SearchLineHistoryNext,
415    /// MB.5c: `<C-x><C-e>` on the `/`·`?` search line — toggle the
416    /// expanded tier-2 mini-buffer band.
417    SearchLineToggleExpand,
418    /// `<CR>` in `prompt-line-mode` (`Effect::OpenPrompt`'s generic
419    /// one-line text prompt) — submit the typed text to whichever
420    /// `action:*` handler the caller named as `on_submit_action`.
421    /// Drives `Editor::do_prompt_line_submit`.
422    PromptLineSubmit,
423    /// `<Esc>` / `<C-c>` in `prompt-line-mode` — cancel without
424    /// firing anything, restore the prior editing buffer.
425    PromptLineCancel,
426    /// Lattice's `-`. Open / step up in the oil-style directory
427    /// view (DESIGN.md §5.9.4). Promoted from
428    /// `Action::OilNavigateUp` in slice 8.i.1.e.
429    OilNavigateUp,
430    /// Vim's `gv`. Reselect the last Visual selection (same
431    /// kind, anchor, head). Promoted from
432    /// `Action::ReselectLastVisual` in slice 8.i.1.e.
433    ReselectLastVisual,
434    /// Vim's `o` in Visual mode -- swap the cursor (head) to the
435    /// other end of the selection (and back). Anchor and head trade
436    /// places so subsequent motions / text objects grow or shrink the
437    /// selection at the end the cursor now sits on.
438    SwapVisualEnds,
439    /// Vim's `p`. Paste the unnamed register's contents after
440    /// the cursor. Promoted from `Action::PasteAfter` in slice
441    /// 8.i.1.e.
442    PasteAfter,
443    /// Vim's `P`. Paste the unnamed register's contents before
444    /// the cursor. Promoted from `Action::PasteBefore` in slice
445    /// 8.i.1.e.
446    PasteBefore,
447    // L7: the 6 nav `AppEffect::Lsp*Request` variants (`gd` / `gD` / `gy`
448    // / `gI` / `gr` / `gx`) removed — they are mode-owned now. `lsp-mode`'s
449    // `action_handlers()` closures emit `Effect::Lsp(LspRequest::{Definition,
450    // Declaration, TypeDefinition, Implementation, References, FollowLink})`,
451    // dispatched host-side by `editor.lsp_request`.
452    /// Vim's `a`. Move cursor one byte right (clamped) and enter
453    /// Insert. Promoted from `Action::EnterAppend` in slice
454    /// 8.i.1.g.
455    EnterAppend,
456    /// Vim's `I`: move cursor to first non-blank column of the
457    /// current line and enter Insert.
458    EnterInsertFirstNonBlank,
459    /// Vim's `A`: move cursor to end of the current line and enter
460    /// Insert.
461    EnterAppendEndOfLine,
462    /// Vim's `gj`: move down one display line (wrap segment).
463    /// Degrades to `j` when wrapping is off.
464    DisplayLineDown,
465    /// Vim's `gk`: move up one display line (wrap segment).
466    /// Degrades to `k` when wrapping is off.
467    DisplayLineUp,
468    /// Vim's `g0`: move to the first byte of the current display segment.
469    /// Degrades to `0` when wrapping is off.
470    DisplayLineStart,
471    /// Vim's `g$`: move to the last byte of the current display segment.
472    /// Degrades to `$` when wrapping is off.
473    DisplayLineEnd,
474    /// Vim's `zf`. Create a fold from the most recent Visual
475    /// selection. Promoted from `Action::CreateFoldFromVisual`
476    /// in slice 8.i.1.g.
477    CreateFoldFromVisual,
478    /// VM.3h: vim's `zf` operator (`zf{motion}`, `{Visual}zf`). A CLOSED fold
479    /// over the pre-resolved inclusive 0-based lines. Carries the span, so it
480    /// crosses WIT like `NarrowLines`.
481    CreateFold {
482        /// First line of the fold (0-based, inclusive).
483        start_line: u32,
484        /// Last line of the fold (0-based, inclusive).
485        end_line: u32,
486    },
487    /// Insert mode's `<BS>`. Delete the byte before the cursor.
488    /// Promoted from `Action::DeleteCharBackward` in slice
489    /// 8.i.1.g.
490    DeleteCharBackward,
491    /// Insert-mode line editing — the readline/vim chords (`<C-a>`, `<C-e>`,
492    /// `<C-w>`, `<C-u>`, `<C-k>`, `<C-t>`, `<C-d>`, …) available in every
493    /// buffer. One grouped effect keyed by [`InsertLineEdit`] so the whole
494    /// family shares a single host handler.
495    InsertLineEdit(InsertLineEdit),
496    /// Insert mode's `<C-Space>` and `<C-x><C-o>`. Trigger the
497    /// completion popup (omni-completion alias). Promoted from
498    /// `Action::CompletionTrigger` in slice 8.i.1.g. The
499    /// completion-popup minor-mode layer's `<C-Space>` binding
500    /// keeps its legacy `bind_action` registration until that
501    /// helper picks up `CommandInvocation` (separate scope).
502    CompletionTrigger,
503    // SN.3c.1 (2026-06-14): `AppEffect::SnippetExpand` removed.
504    // `<C-x><C-s>` is now mode-owned (`snippet-mode`'s `keymap()` +
505    // `action_handlers()`): the handler scans the word prefix and
506    // emits `Effect::ExpandSnippet { replace_range }`, which the host
507    // resolves + expands. No host `Action` / `AppEffect` round-trip.
508    /// Visual mode's `<Esc>` / `v` / `V`. Exit Visual to Normal,
509    /// collapsing the selection. Promoted from `Action::ExitVisual`
510    /// in slice 8.i.1.h.
511    ExitVisual,
512    /// Replace mode's `<BS>`. Undo the last overwritten char.
513    /// Promoted from `Action::ReplaceUndoLast` in slice 8.i.1.h.
514    ReplaceUndoLast,
515    /// Vim's `i` / `R` / `<Esc>` (from Insert / Replace) -- enter
516    /// the named modal state. Promoted from
517    /// `Action::EnterMode(_)` in slice 8.i.2.a. Each chord in the
518    /// keymap binds a *distinct* `CommandId` whose `ActionSpec`
519    /// returns the right `AppEffect::EnterMode(state)` constant
520    /// -- the `ModalState` rides in the AppEffect rather than in
521    /// `CommandInvocation::args` so the App's `apply_app_effect`
522    /// matches a single `EnterMode(state)` arm instead of N
523    /// param-flat variants.
524    EnterMode(ModalState),
525    /// Vim's `v` / `V` / `<C-v>` -- enter Visual with the named
526    /// kind anchored at the current cursor. Promoted from
527    /// `Action::EnterVisual(_)` in slice 8.i.2.a. Same encoding
528    /// as [`Self::EnterMode`]: distinct `CommandId` per kind,
529    /// payload rides in the AppEffect.
530    EnterVisual(VisualKind),
531    /// Vim's `gh` / `gH` / `g<C-h>` -- enter Select mode (SN.3d) with
532    /// the named kind, anchored at the current cursor. Same encoding as
533    /// [`Self::EnterVisual`]: distinct `CommandId` per kind, payload
534    /// rides in the AppEffect. The host handler (`do_enter_select`)
535    /// anchors a zero-width selection like `do_enter_visual` does —
536    /// typing then overtypes it. Programmatic entry (snippets) instead
537    /// uses `EnterMode(Select(k))` with an explicit selection already
538    /// set; see `docs/dev/architecture/select-mode.md` §3.
539    EnterSelect(VisualKind),
540    /// Vim's `/` / `?` -- enter the Search minibuffer in the
541    /// named direction. Promoted from `Action::EnterSearch(_)`
542    /// in slice 8.i.2.b. Same encoding as [`Self::EnterMode`]:
543    /// distinct `CommandId` per direction.
544    EnterSearch(SearchDirection),
545    /// Vim's `*` / `#` -- search for the word under the cursor
546    /// in the named direction. Promoted from
547    /// `Action::SearchWordUnderCursor(_)` in slice 8.i.2.b.
548    SearchWordUnderCursor(SearchDirection),
549    /// Vim's `H` / `M` / `L` -- jump cursor to the named
550    /// position within the visible viewport. Promoted from
551    /// `Action::JumpViewport(_)` in slice 8.i.2.c.
552    JumpViewport(ViewportPos),
553    /// Vim's `zz` / `zt` / `zb` -- scroll the viewport so the
554    /// cursor's current line sits at the named position.
555    /// Promoted from `Action::ScrollCursorTo(_)` in slice 8.i.2.c.
556    ScrollCursorTo(ScrollPos),
557    /// HS.2: vim `z{l,h,L,H,s,e}` manual horizontal scroll.
558    HorizontalScroll(HScroll),
559    /// Vim's `J` (with-space) / `gJ` (no-space). Joins the
560    /// current line with the next, replacing the joining
561    /// newline with a single space (`with_space: true`) or
562    /// nothing (`false`). Promoted from `Action::JoinLines` in
563    /// slice 8.i.2.d. Bool payload rides in the AppEffect:
564    /// distinct `CommandId` per binding (`J` -> with-space=true,
565    /// `gJ` -> with-space=false).
566    JoinLines {
567        /// `true` for `J` (joined with a space), `false` for `gJ` (no
568        /// space inserted).
569        with_space: bool,
570    },
571    /// Vim's `;` (forward) / `,` (reverse). Repeat the most
572    /// recent `f` / `F` / `t` / `T` find on the current line
573    /// in the originally-typed direction (`reverse: false`) or
574    /// the opposite direction (`reverse: true`). Promoted from
575    /// `Action::FindRepeat` in slice 8.i.2.d.
576    FindRepeat {
577        /// `false` for `;` (same direction as the original find), `true`
578        /// for `,` (opposite direction).
579        reverse: bool,
580    },
581    /// Insert / Replace mode's `<CR>`. Inserts a literal newline
582    /// at the cursor. Promoted from
583    /// `Action::Insert("\n".into())` in slice 8.i.2.e. Distinct
584    /// flat variant rather than a `String`-payload `Insert(_)`
585    /// because the keymap-bound forms always pin a fixed
586    /// literal; the wildcard "type any printable char" path
587    /// stays on `Action::Insert(c.to_string())` since it isn't
588    /// keymap-bound.
589    InsertNewline,
590    /// Insert mode's `<Tab>`. Inserts a literal tab at the
591    /// cursor. Promoted from `Action::Insert("\t".into())` in
592    /// slice 8.i.2.e.
593    InsertTab,
594    /// Replace mode's bare-printable wildcard. Overwrites the
595    /// byte at the cursor with the captured char. Promoted from
596    /// `Action::OverwriteChar(c)` in slice 8.i.3.
597    OverwriteChar(char),
598    /// Vim's `m<X>`. Sets mark `<X>` at the cursor's current
599    /// position. Promoted from `Action::SetMark(c)` in slice
600    /// 8.i.3. The bound `ActionSpec` validates that `<X>` is
601    /// `[a-zA-Z0-9]` -- invalid chars dispatch to `Effect::None`
602    /// (effectively a no-op; `App::apply` clears the pending
603    /// state on every Invoke).
604    SetMark(char),
605    /// Vim's `'<X>`. Jumps the cursor to the line of mark `<X>`.
606    /// Promoted from `Action::JumpToMarkLine(c)` in slice 8.i.3.
607    JumpToMarkLine(char),
608    /// Vim's `` `<X> ``. Jumps the cursor to the exact position
609    /// (line + byte) of mark `<X>`. Promoted from
610    /// `Action::JumpToMarkExact(c)` in slice 8.i.3.
611    JumpToMarkExact(char),
612    /// Vim's `"<X>`. Selects the named register for the next
613    /// yank / paste / delete. Promoted from
614    /// `Action::SelectRegister(_)` in slice 8.i.3. The bound
615    /// `ActionSpec` validates the captured char via
616    /// [`Register::from_input_char`]; chars that don't name a
617    /// register dispatch to `Effect::None`.
618    SelectRegister(Register),
619    /// Vim's `q<X>`. Starts recording a macro into register
620    /// `<X>`. Promoted from `Action::StartMacroRecord(c)` in
621    /// slice 8.i.3.
622    StartMacroRecord(char),
623    /// Vim's `@<X>` for `<X>` alphanumeric. Plays the macro
624    /// stored in register `<X>`. Promoted from
625    /// `Action::PlayMacro(c)` in slice 8.i.3. The `@@` chord is
626    /// dispatched to [`Self::PlayLastMacro`] from the same
627    /// `play-macro` action (the spec branches on the captured
628    /// char).
629    PlayMacro(char),
630    /// Vim's `@@`. Replays the most recently played macro.
631    /// Promoted from `Action::PlayLastMacro` in slice 8.i.3.
632    /// Shares its bind site (`@<CharLiteral>`) with
633    /// [`Self::PlayMacro`]; the `play-macro` action's apply
634    /// closure picks one or the other based on the captured
635    /// char.
636    PlayLastMacro,
637    /// Slice 8.i.4.c: arm an operator-pending state via the
638    /// `partial_chord` mechanism. The App handler does two
639    /// things atomically:
640    ///
641    /// 1. Latch `pending_count` into `op_count` (vim's
642    ///    `<count>op<motion>` count multiplication; without
643    ///    this `2dd` would get a count of 1).
644    /// 2. Push the operator's chord prefix into
645    ///    `App::partial_chord` so the next keystroke routes
646    ///    through `lookup_normal_with_prefix` and resolves
647    ///    `[op, motion]` / `[op, i/a, text-object]` /
648    ///    `[op, f/F/t/T, char]` to the bound `Invoke`.
649    ///
650    /// Replaces the `Action::SetPending(Pending::AfterOperator(_))`
651    /// flow that did the same two things split across the
652    /// keymap (which fired `SetPending`) and `App::apply`
653    /// (which latched `op_count` inside the SetPending arm).
654    /// The 8 operator prefixes -- `d`, `c`, `y`, `>`, `<`,
655    /// `gU`, `gu`, `g~` -- bind to typed actions whose
656    /// `ApplySpec` returns this variant.
657    AbsorbOperatorPrefix(OperatorId),
658    /// Vim's `<C-w>s`: split the active pane horizontally.
659    /// Promoted from `Action::SplitPaneHorizontal` in slice 8.i.4.d.
660    SplitPaneHorizontal,
661    /// Vim's `<C-w>v`: split the active pane vertically.
662    /// Promoted from `Action::SplitPaneVertical` in slice 8.i.4.d.
663    SplitPaneVertical,
664    /// Vim's `<C-w>c` / `<C-w>q`: close the active pane.
665    /// Promoted from `Action::ClosePane` in slice 8.i.4.d.
666    ClosePane,
667    /// Vim's `<C-w>o` / `:only` / emacs `C-x 1`: close every pane
668    /// except the active one (collapse the tree to the active leaf).
669    /// No-op when only one pane is open. S3b (2026-06-22).
670    OnlyPane,
671    /// ZP.2: `<C-w>z` / `<C-w><C-z>` / `:zoom-pane` — toggle
672    /// tmux-style zoom on the active pane. Non-destructive, unlike
673    /// [`Self::OnlyPane`]: the split layout is preserved and the
674    /// second toggle restores it verbatim. No-op on a single-pane
675    /// tab. See `docs/dev/architecture/pane-zoom.md`.
676    ToggleZoomPane,
677    /// Vim's `<C-w>h/j/k/l` (and arrow / `<BS>` aliases): move
678    /// focus to the pane in the named direction. Promoted from
679    /// `Action::NavigatePane(_)` in slice 8.i.4.d.
680    NavigatePane(PaneDirection),
681    /// Vim's `<C-w>w` / `<C-w><Tab>`: cycle focus to the next
682    /// pane. Promoted from `Action::NextPane` in slice 8.i.4.d.
683    NextPane,
684    /// Vim's `<C-w>W` / `<C-w><S-Tab>`: cycle focus to the
685    /// previous pane. Promoted from `Action::PrevPane` in
686    /// slice 8.i.4.d.
687    PrevPane,
688    /// Issue #29 (2026-05-22): vim's `gt` — next tab.
689    NextTab,
690    /// Vim's `gT` — previous tab.
691    PrevTab,
692    /// Vim's `{N}gt` — switch to tab N (1-indexed; clamped).
693    GoToTab(u32),
694    /// `:tabnew` — new empty tab.
695    NewTab,
696    /// `:tabnew <path>` — new tab opening `path`.
697    NewTabAt(String),
698    /// Issue #40 / Terminal-mode T1 (2026-05-22):
699    /// `:terminal [cmd]` — spawn a PTY-backed shell.
700    TerminalSpawn(Option<String>),
701    /// T4 (2026-05-25): `:tabterminal [cmd]` — open a fresh
702    /// tab and spawn a PTY-backed shell in it. Handler does
703    /// `do_new_tab` then `do_terminal_spawn(cmd)`.
704    TerminalSpawnInNewTab(Option<String>),
705    /// T4 (2026-05-25): `<C-w>T` — move the active pane to a
706    /// fresh tab. Handler in `Editor::do_move_pane_to_new_tab`.
707    MovePaneToNewTab,
708    /// `:tabclose` — close active tab.
709    CloseTab,
710    /// `:tabonly` — close every tab except the active one.
711    OnlyTab,
712    /// `:tabmove [N]` — move active tab to position N (1-indexed).
713    MoveTab(u32),
714    /// Issue #32 (2026-05-22): picker open-target overrides.
715    /// `<C-s>` — accept selected candidate in horizontal split.
716    PickerAcceptInSplit,
717    /// `<C-v>` — accept selected candidate in vertical split.
718    PickerAcceptInVSplit,
719    /// `<C-t>` — accept selected candidate in new tab.
720    PickerAcceptInTab,
721    /// Issue #28 (2026-05-22): `<C-w>=` — reset every split's
722    /// ratio to 0.5.
723    EqualizePanes,
724    /// `<C-w>+` — grow the active pane vertically.
725    GrowPaneHeight,
726    /// `<C-w>-` — shrink the active pane vertically.
727    ShrinkPaneHeight,
728    /// `<C-w>>` — grow the active pane horizontally.
729    GrowPaneWidth,
730    /// `<C-w><` — shrink the active pane horizontally.
731    ShrinkPaneWidth,
732    /// Completion-popup overlay: focus the next entry. Promoted
733    /// from `Action::CompletionNext` in slice 8.i.4.e.
734    CompletionNext,
735    /// Completion-popup overlay: focus the previous entry.
736    /// Promoted from `Action::CompletionPrev` in slice 8.i.4.e.
737    CompletionPrev,
738    /// Completion-popup overlay: accept the focused candidate.
739    /// Promoted from `Action::CompletionAccept` in slice
740    /// 8.i.4.e.
741    CompletionAccept,
742    /// Completion-popup overlay: cancel the popup, stay in
743    /// Insert. Promoted from `Action::CompletionCancel` in
744    /// slice 8.i.4.e.
745    CompletionCancel,
746    /// Completion-popup overlay: cancel the popup and exit
747    /// Insert. Promoted from
748    /// `Action::CompletionCancelAndExitInsert` in slice 8.i.4.e.
749    CompletionCancelAndExitInsert,
750    /// Completion-popup overlay: toggle the doc-popup
751    /// (`<C-d>`). Promoted from `Action::CompletionToggleDocs`
752    /// in slice 8.i.4.e.
753    CompletionToggleDocs,
754    /// Completion-popup overlay: scroll the doc-popup down
755    /// (`<C-f>`). Promoted from
756    /// `Action::CompletionDocsScrollDown` in slice 8.i.4.e.
757    CompletionDocsScrollDown,
758    /// Completion-popup overlay: scroll the doc-popup up
759    /// (`<C-b>`). Promoted from
760    /// `Action::CompletionDocsScrollUp` in slice 8.i.4.e.
761    CompletionDocsScrollUp,
762    /// Completion-popup overlay: bare-printable wildcard.
763    /// Accept the focused candidate, then insert the captured
764    /// char (so the user can finish typing through a
765    /// confirmed prefix). Promoted from
766    /// `Action::CompletionAcceptThenInsert(c)` in slice
767    /// 8.i.4.e.
768    CompletionAcceptThenInsert(char),
769    /// YR.5: vim's insert-register — `<C-r>` then a register char,
770    /// inserting that register's contents at the cursor.
771    InsertRegister(char),
772    /// YR.5: `<C-r><C-r>` — open the yank-ring picker, filling whichever
773    /// surface it was opened from.
774    OpenYankPicker,
775    /// YR.6: open the picker registered for the `:`-line argument under
776    /// the cursor (`ArgSpec.picker`), filling the pick back into it.
777    OpenArgPicker,
778    /// Active-snippet overlay: jump to the next placeholder
779    /// (`<Tab>`). Promoted from
780    /// `Action::SnippetNextPlaceholder` in slice 8.i.4.e.
781    SnippetNextPlaceholder,
782    /// Active-snippet overlay: jump to the previous placeholder
783    /// (`<S-Tab>`). Promoted from
784    /// `Action::SnippetPrevPlaceholder` in slice 8.i.4.e.
785    SnippetPrevPlaceholder,
786    // SN.3c.2 (2026-06-14): `AppEffect::SnippetLeave` removed.
787    // `<Esc>` is mode-owned now (`active-snippet-mode`'s
788    // `keymap()` binds it + a per-buffer closure in `on_activate`
789    // clears the session + returns `Effect::EnterMode(Normal)`);
790    // no host `Action` / `AppEffect` round-trip. (Unlike the nav
791    // placeholders, which keep their `register_simple`-produced
792    // AppEffect variants as no-ops, leave switched to
793    // `register_action`, so this variant had no producer left.)
794    /// Completion-popup overlay: restrict candidates to a single
795    /// source. The string is the `SourceId` (e.g.
796    /// `"gen:buffer-words"`, `"gen:lsp-completion"`). Bound to
797    /// the popup-mode filter chords (`<C-b>`, `<C-o>`, `<C-f>`,
798    /// `<C-t>`, ...) introduced in CSM.K2.
799    CompletionFilterToSource(String),
800    /// Completion-popup overlay: clear the active source filter
801    /// (`<C-Space>`). Restores the mixed merged candidate list.
802    CompletionFilterClear,
803    /// D.5.b (2026-05-30): diff-mode `do` (diff-get) operator.
804    /// CR.1 (2026-06-24): `do` is now mode-owned
805    /// (`DiffMode::action_handlers()` → `Effect::ApplyEdit`); this
806    /// `AppEffect` is retained only as the FALLBACK the `action:diff-get`
807    /// CommandSpec emits when no handler is registered. The host's
808    /// `apply_app_effect` arm is emptied to a silent no-op (the
809    /// `Action::DiffGet` variant it used to push is deleted).
810    DiffGet,
811    /// D.5.c (2026-05-30): diff-mode `dp` (diff-put) operator.
812    /// CR.1: mirror of [`Self::DiffGet`] — mode-owned now; retained as
813    /// the emptied `action:diff-put` fallback shell.
814    DiffPut,
815    /// T.3: tutor-mode `<CR>` / `:tutor-next`. Advance to the
816    /// next exercise; advance to the next lesson when the
817    /// current one is complete. No-op when tutor-mode is not
818    /// active on the current buffer.
819    TutorAdvance,
820    /// T.3: tutor-mode `:tutor-prev`. Retreat to the previous
821    /// exercise. No-op at exercise 0.
822    TutorRetreat,
823    /// M.5 (2026-06-01): `:multibuffer-expand [n]` /
824    /// `:multibuffer-contract [n]` ex-commands. `delta` is the
825    /// signed row count (positive expands, negative contracts).
826    /// Routed to the active multibuffer view's `expand_excerpt_at`
827    /// from the dispatch path. No-op when the active buffer
828    /// isn't a multibuffer.
829    MultibufferExpand {
830        /// Context lines to add (positive) or remove (negative) around the
831        /// excerpt under the cursor.
832        delta: i32,
833    },
834    /// N.1.1 (2026-06-10): `:narrow [{range}]` ex-command. The host
835    /// arm resolves `range` against the active document to a line
836    /// span, fetches the active buffer's `Arc<dyn Document>`, and
837    /// calls `lattice_multibuffer::providers::narrow::create_narrow_view`
838    /// — opening a one-excerpt multibuffer focused on that region.
839    /// `range == None` (bare `:narrow`) narrows the current paragraph
840    /// (blank-line delimited, vim's `ip`).
841    NarrowTrigger {
842        /// The unresolved `:` range, resolved by the host against cursor,
843        /// last Visual selection and marks (see [`crate::Range`] for which
844        /// forms resolve); `None` = current paragraph.
845        range: Option<crate::range::Range>,
846    },
847    /// N.1.1 (2026-06-10): `:widen` ex-command. The host arm closes
848    /// the active narrow view (an editable one-excerpt multibuffer),
849    /// restoring the full source buffer. No-op + echo when the
850    /// active buffer isn't a narrow view.
851    NarrowWiden,
852    /// N.1.3 (2026-06-10): the `zn` narrow operator emits this once
853    /// the operator-pending machinery has resolved its motion / text
854    /// object to a line span. Carries pre-resolved inclusive 0-based
855    /// `[start_line, end_line]` (unlike `NarrowTrigger`, which carries
856    /// an unresolved `Range`); the host arm narrows the active buffer
857    /// to that span via the same `create_narrow_view` sink.
858    NarrowLines {
859        /// First line of the region (0-based, inclusive).
860        start_line: u32,
861        /// Last line of the region (0-based, inclusive).
862        end_line: u32,
863    },
864    /// M.6 (2026-06-01): `:search <query>` ex-command. M.10.6
865    /// (2026-06-03) inlined the work into the host's
866    /// apply_effect arm — it calls
867    /// `lattice_multibuffer::providers::search::project_search`
868    /// against the active editor as the activator. No longer
869    /// trampolines through `Action::SearchTrigger` /
870    /// `Editor::do_search` (both deleted).
871    SearchTrigger {
872        /// The search query, passed to the project-search provider as
873        /// typed.
874        query: String,
875    },
876    /// M.6.1 (2026-06-01): `gr` chord in project-search-mode.
877    /// Re-runs the scan with the view's current query. M.10.5
878    /// (2026-06-03) made this mode-owned: the search mode's
879    /// `on_activate` registers a closure that intercepts via
880    /// `ActionHandlerRegistry` before this AppEffect arm runs.
881    /// The arm is a no-op marker; the work happens in the
882    /// mode's closure (reading state, clearing excerpts,
883    /// spawning a fresh scan task).
884    SearchRefresh,
885    /// CM.1 (2026-07-21): `:compile <cmd>` / `:recompile` / `:make`.
886    /// The host arm creates the read-only synthetic `*compilation*`
887    /// buffer host-side (`Editor::ensure_named_synthetic_document`,
888    /// activating `compilation-mode`) and kicks off the pipe-captured
889    /// off-thread run via the `CompilationServiceHandle`, whose output
890    /// streams into that buffer. Same
891    /// shape as `SearchTrigger`: the substrate crate owns the work;
892    /// the host arm is generic apply-effect routing. `cmdline`:
893    /// `Some(cmd)` for `:compile`; `None` for `:recompile` / bare
894    /// `:make` (reuse the last command).
895    CompileRun {
896        /// Shell command line to run; `None` re-runs the last one.
897        cmdline: Option<String>,
898    },
899    /// CM.3b (2026-07-22): `<CR>` on a location line in the
900    /// `*compilation*` buffer. The `compilation-mode` action handler
901    /// parses the cursor line's text (`parse_location_line`) into a
902    /// source location and emits this; the host arm calls
903    /// `Editor::jump_to_file_line_col(&path, line, col)` (records the
904    /// hop in position history) and syncs the error list index to the
905    /// matching entry. `line` / `col` are 0-based. The location rides
906    /// in the AppEffect because the jump target is computed off the
907    /// buffer text in the mode's closure, but the error list index is
908    /// core/host state — so the host owns the apply.
909    CompileJumpToLocation {
910        /// File to open (activated if already open).
911        path: std::path::PathBuf,
912        /// 0-based line; clamped to the file's last line.
913        line: u32,
914        /// 0-based **byte** column; clamped to the line's length.
915        col: u32,
916    },
917    /// CM.2 (2026-07-22): `:cnext`/`:cprev`/`:cc [N]`/`:cfirst`/
918    /// `:clast` and the Builtin `]q`/`[q` chords. The host arm calls
919    /// `Editor::do_error_nav`, which walks the core error list
920    /// (recording each hop in position history via
921    /// `jump_to_file_line_col`). On an empty list every target echoes
922    /// "no error list" and moves nothing -- there is no fallback to
923    /// diagnostic hopping (`]d` / `[d` own that).
924    ErrorNav {
925        /// Which entry to jump to.
926        target: ErrorTarget,
927    },
928    /// CM.3a (2026-07-22): parsed error entries from the compilation
929    /// stderr reader — the off-thread → host-state seam. The reader
930    /// accumulates entries and sends the FULL list through the
931    /// compilation inbound bus; this handler maps each send here, and
932    /// the host arm calls `Editor::set_error_list(entries)`
933    /// (replace-semantics — the growing list stays visible). An empty
934    /// vec (sent on a new run / `:recompile`) clears the stale list.
935    /// The parser (below-host `lattice-compilation`) and this payload
936    /// share the `lattice_protocol::error_list::ErrorEntry` type.
937    SetErrorList {
938        /// EP.1 (2026-08-10): which producer this run came from. The
939        /// host arm replaces only this source's slice, so a language
940        /// server republishing on edit-debounce cannot wipe a compile
941        /// run's entries while the user walks them.
942        source: lattice_protocol::error_list::ErrorSource,
943        /// EP.2: new run (reset the index) vs live refresh (re-anchor
944        /// it). Declared by the producer — not inferrable from the
945        /// entries.
946        write: lattice_protocol::error_list::ErrorWrite,
947        /// The source's complete entry list (replace, not append). Empty
948        /// clears this source's slice.
949        entries: Vec<lattice_protocol::error_list::ErrorEntry>,
950    },
951    /// CM.3c (2026-07-22): the per-buffer severity gutter index for the
952    /// `*compilation*` buffer — the off-thread → host-state seam for
953    /// in-buffer severity marks (twin of `SetErrorList`, which feeds the
954    /// cross-file error list). The compilation drain scans each
955    /// streamed line for a severity keyword, accumulates the FULL
956    /// per-buffer index of `(line, severity)`, and sends it through the
957    /// compilation inbound bus; this handler maps each send here, and the
958    /// host arm converts the severities to `GutterSeverityLevel` and writes
959    /// the `render_state` compilation-severity slot for `buffer`. The
960    /// renderer reads that slot and injects it into the mode's
961    /// `gutter_decorations` via `CompilationSeverityData`. An empty vec
962    /// (sent on `Reset` / a new run) clears the buffer's marks.
963    ///
964    /// `buffer` is the raw `BufferId.0` (`BufferId` is process-local and
965    /// not `Serialize`, so the wire form is the `u32`; the host arm
966    /// reconstructs `BufferId`). Severity rides as the parser-native
967    /// `ErrorSeverity` (already shared with `SetErrorList`) — the single
968    /// map to the renderer-facing `GutterSeverityLevel` happens host-side,
969    /// avoiding a lossy `GutterSeverityLevel`↔`ErrorSeverity` round-trip
970    /// and keeping `lattice-grammar` free of a `lattice-mode` dependency.
971    CompilationGutterSet {
972        /// `BufferId.0` of the compilation buffer.
973        buffer: u32,
974        /// `(0-based line, severity)` for every marked line; the full
975        /// index, replacing the previous one.
976        entries: Vec<(u32, lattice_protocol::error_list::ErrorSeverity)>,
977    },
978    /// CM.3c (2026-07-22): per-buffer compilation location-line
979    /// index for theme-based highlighting of navigable lines in
980    /// the `*compilation*` buffer. Twin of `CompilationGutterSet`:
981    /// the off-thread compilation drain scans each chunk for
982    /// location-bearing lines (via `parse_location_line`) and
983    /// ships the full list through an inbound bus; this effect
984    /// writes the `render_state` compilation-location slot for
985    /// `buffer`. An empty vec (sent on `Reset` / a new run)
986    /// clears the buffer's location-line set.
987    CompilationLocationLines {
988        /// `BufferId.0` of the compilation buffer.
989        buffer: u32,
990        /// (line, path_byte_start, path_byte_end) for each location line.
991        /// byte_start/end are the byte offsets of the file-path portion
992        /// within the line text, for link-like fg highlighting.
993        lines: Vec<(u32, u32, u32)>,
994    },
995    /// CM.3d (2026-07-22): resolved compilation location theme colours
996    /// — published by the mode during activation so the renderer
997    /// reads `compilation.location` bg/fg from the theme rather than
998    /// hardcoding RGB values.
999    ///
1000    /// Stored editor-wide (not per buffer); the latest send wins.
1001    CompilationThemeColors {
1002        /// Background of a location line, packed `0xRRGGBB`.
1003        bg: u32,
1004        /// Foreground of a location line's path, packed `0xRRGGBB`.
1005        fg: u32,
1006    },
1007    /// CM.3d (2026-07-22): kill the running compilation child
1008    /// process. The host arm calls `CompilationService::kill()`.
1009    CompilationKill,
1010    /// CM.4 (2026-07-22): `:copen`. The host arm reads the core
1011    /// error list and calls
1012    /// `lattice_multibuffer::providers::problems::create_problems_view`,
1013    /// opening the `*problems*` multibuffer — the error entries
1014    /// grouped as editable source excerpts by file. Same shape as
1015    /// `SearchTrigger`: the substrate crate owns view-creation; the
1016    /// host arm is generic apply-effect routing. Echoes "no error
1017    /// list" when the list is empty.
1018    ProblemsOpen,
1019    /// CM.4 (2026-07-22): `:cclose`. The host arm closes the active
1020    /// `*problems*` view (an editable multibuffer with
1021    /// `ProblemsMinorMode`), leaving the source buffers open — the
1022    /// `NarrowWiden` close shape, guarded to problems views. No-op +
1023    /// echo when the active buffer isn't a problems view.
1024    ProblemsClose,
1025    /// RV.3 (2026-08-10): `gr` in a `*problems*` view — rebuild its
1026    /// excerpts from the *current* error list, in place.
1027    ///
1028    /// Deliberately NOT a re-fire of [`Self::ProblemsOpen`]:
1029    /// `create_multibuffer_view` mints a fresh `BufferId` on every
1030    /// call, so re-opening would leave the old view behind and add a
1031    /// second `*problems*` buffer rather than refreshing the one the
1032    /// user is looking at. The host arm instead re-reads the error
1033    /// list and hands it to
1034    /// `lattice_multibuffer::providers::problems::refresh_problems_view`,
1035    /// which swaps sources + excerpts atomically via
1036    /// `replace_excerpts`. Same division as `ProblemsOpen`: the
1037    /// substrate crate owns the rebuild, the host arm is generic glue.
1038    ProblemsRefresh,
1039    /// PV.1 (2026-08-12): **open the multibuffer view a provider owns**
1040    /// — the generic replacement for the per-provider trigger variant.
1041    ///
1042    /// A multibuffer view can only be created through `ModeActivator`,
1043    /// which is `&mut`-backed and therefore host-only, so every provider
1044    /// trigger has to round-trip through an effect. `SearchTrigger` /
1045    /// `ProblemsOpen` each spent a variant here plus a host match arm
1046    /// plus a plugin-boundary arm on exactly that — three crates touched
1047    /// per provider, against a design whose acid test is that a provider
1048    /// crate touches none.
1049    ///
1050    /// Here the host arm is provider-agnostic: look `provider` up in the
1051    /// `ProviderViewRegistry`, call the registered opener with the host
1052    /// as the activator and `args` verbatim, then apply the generic
1053    /// outcome (activate + echo, or echo the refusal). What the opener
1054    /// computes, and how it words its own success and refusal, stays in
1055    /// the provider's crate.
1056    ///
1057    /// `args` is the trigger's parameters as the `:` line or transient
1058    /// row produced them — the same `Args` shape a command handler
1059    /// receives, so one opener serves both front-ends.
1060    ///
1061    /// `:narrow` / `zn` deliberately do NOT route through here; see
1062    /// [`Self::NarrowTrigger`] and `lattice_mode::provider_view`'s module
1063    /// docs for why a range resolved against cursor / Visual / marks is
1064    /// not a provider parameter.
1065    OpenProviderView {
1066        /// Name the provider registered its opener under in the
1067        /// `ProviderViewRegistry`. An unknown name echoes a warning naming
1068        /// it.
1069        provider: String,
1070        /// The trigger's parameters, passed to the opener verbatim.
1071        args: crate::args::Args,
1072    },
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    #![allow(clippy::unwrap_used, clippy::panic)]
1078    use super::*;
1079
1080    #[test]
1081    fn quit_round_trips_through_serde() {
1082        let q = AppEffect::Quit;
1083        let s = serde_json::to_string(&q).unwrap();
1084        let back: AppEffect = serde_json::from_str(&s).unwrap();
1085        assert_eq!(q, back);
1086    }
1087}