Skip to main content

lattice_grammar/
effect.rs

1//! What a `CommandInvocation` produced once executed.
2//!
3//! [`Effect`] is the **host boundary**: every evaluator, ex-command, mode
4//! action handler and WASM plugin describes what it wants done as an
5//! `Effect` value, and the host (`lattice-host`'s `handle_effect`, then the
6//! renderer peers for the few renderer-coupled arms) applies it. Producers
7//! never hold `&mut Editor`; that is what makes built-ins, modes and plugins
8//! peers, and what lets macros, dot-repeat and the async inbound path replay
9//! the same values.
10//!
11//! `Effect::None` is for read-only or selection-only commands. `Effect::Edits`
12//! carries the `AppliedEdit`s that the dispatcher applied to the document
13//! (suitable for `Event::DocumentChanged`). `Effect::SelectionChange` carries
14//! the new selection set (suitable for `Event::SelectionsChanged`). Effects
15//! compose; a single command can yield multiple via `Effect::Many`.
16//!
17//! # Coordinates
18//!
19//! Every [`Position`](lattice_protocol::position::Position) here is 0-based
20//! `(line, byte)` -- a UTF-8 byte offset within the line, not a char or
21//! UTF-16 column -- and every protocol `Range` is half-open. The one
22//! exception is [`Utf16Pos`], which exists precisely to carry an
23//! unconverted LSP column.
24//!
25//! # Which buffer
26//!
27//! Unless a variant names a buffer (`target`, `view`, a `BufferId`, a
28//! path or a synthetic name), it acts on the **focused** buffer / active
29//! pane *at apply time*. That is right for a chord-time effect and wrong
30//! for an async one; see [`Effect::CursorMoveIn`] and
31//! [`Effect::ApplyEdit`] for the addressed forms.
32//!
33//! # Where each variant is applied
34//!
35//! Most arms run host-side in `lattice_host::dispatch::handle_effect`,
36//! synchronously and in order. A minority are *peer-applied* (the TUI's
37//! `apply_effect_app_arms` and the GPUI peer): `QuitEditor`, `OpenBuffer`,
38//! `OpenBufferAt`, `OpenInTarget`, `Global`, the picker / prompt /
39//! transient / popup / file-tree / oil openers, most `Lsp*` commands. A
40//! peer-applied effect runs *after* every host-applied effect of the same
41//! batch, and is dropped on the off-keystroke inbound path (which applies
42//! host-side only) -- which is why host-applied twins such as
43//! [`Effect::OpenBufferAtColumn`] exist.
44//!
45//! Ex-command effects (`SaveBuffer`, `QuitEditor`, `OpenBuffer`, `SetOption`,
46//! `ClearSearchHighlight`, `Echo`, `EchoRegisters`, `EchoMarks`, `Substitute`,
47//! `Global`) carry the typed intent of an ex-command. The host applies them
48//! using its own state (registers, marks, view options, document loader);
49//! the closure inside the registry only needs to package args into the
50//! correct effect, which is what makes plugin- and built-in ex-commands
51//! peers (DESIGN.md §5.2.1, §5.2.4).
52
53use std::path::PathBuf;
54
55use lattice_core::buffer::AppliedEdit;
56use lattice_protocol::selection::SelectionSet;
57
58use crate::app_effect::AppEffect;
59use crate::command::CommandInvocation;
60use crate::modal::ModalState;
61use crate::register::Register;
62
63/// How a yank captured its content. Drives paste behavior:
64/// charwise yanks land at the cursor, linewise yanks land on the next
65/// line below, blockwise yanks paste each '\n'-separated row at the
66/// same column on consecutive lines (vim's `Ctrl-V` selection then
67/// `y`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub enum YankKind {
70    /// A span of characters; `p` inserts after the cursor, `P` at it.
71    Charwise,
72    /// Whole lines (content ends with a newline); `p` opens below the
73    /// cursor line, `P` above.
74    Linewise,
75    /// A Visual-block rectangle: rows joined by `'\n'`, pasted one per
76    /// line at the cursor's column.
77    Blockwise,
78}
79
80/// Severity tier for `Effect::Echo`. The host's echo-area renderer maps
81/// these to its own colour scheme.
82///
83/// **msg-mode.1 (tracing bridge):** the five variants mirror
84/// `tracing::Level` exactly so `App::set_message` can route through
85/// a single `tracing::event!` call without lossy conversion. `Trace` +
86/// `Debug` are below the default `Info` filter so they don't show
87/// in the echo area today — they exist for subsystems that want
88/// verbose records in `*messages*` (e.g. `editor=trace`, `lsp=debug`)
89/// to surface without inventing a parallel severity scale.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub enum EchoLevel {
92    /// `tracing::Level::TRACE`; recorded, never echoed by default.
93    Trace,
94    /// `tracing::Level::DEBUG`; recorded, never echoed by default.
95    Debug,
96    /// Ordinary feedback ("3 substitutions").
97    Info,
98    /// Something the user should notice but that did not fail.
99    Warn,
100    /// A failed command (vim's `E…` messages).
101    Error,
102}
103
104impl From<EchoLevel> for tracing::Level {
105    fn from(level: EchoLevel) -> Self {
106        match level {
107            EchoLevel::Trace => tracing::Level::TRACE,
108            EchoLevel::Debug => tracing::Level::DEBUG,
109            EchoLevel::Info => tracing::Level::INFO,
110            EchoLevel::Warn => tracing::Level::WARN,
111            EchoLevel::Error => tracing::Level::ERROR,
112        }
113    }
114}
115
116impl From<tracing::Level> for EchoLevel {
117    fn from(level: tracing::Level) -> Self {
118        // `tracing::Level` is a unit-struct wrapper; match via
119        // associated consts because the inner repr is not pub.
120        match level {
121            tracing::Level::TRACE => EchoLevel::Trace,
122            tracing::Level::DEBUG => EchoLevel::Debug,
123            tracing::Level::INFO => EchoLevel::Info,
124            tracing::Level::WARN => EchoLevel::Warn,
125            tracing::Level::ERROR => EchoLevel::Error,
126        }
127    }
128}
129
130/// Scope for `Effect::Substitute`. Mirrors vim's `:s/.../.../` (current
131/// line) vs. `:%s/.../.../` (whole buffer).
132#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
133pub enum SubstituteScope {
134    /// `:s` -- the cursor's line only.
135    CurrentLine,
136    /// `:%s` -- every line of the buffer.
137    Whole,
138}
139
140/// Scope for `Effect::QuitEditor`. Mirrors vim's `:q` (close the active
141/// pane; quit only when it is the last one) vs. `:qa` (quit the editor
142/// regardless of how many panes / tabs are open). `:q` and `:qa` stay
143/// distinct *commands* (separate registrations + aliases); `QuitScope`
144/// is the one axis on which they differ, so the shared shutdown + dirty
145/// guard stays in a single `Editor::do_quit`.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147pub enum QuitScope {
148    /// `:q[!]` -- close the active pane when more than one is open;
149    /// run the dirty guard and shut the editor only on the last pane.
150    Pane,
151    /// `:qa[!]` -- ignore pane / tab count; run the dirty guard and
152    /// shut the editor outright.
153    All,
154}
155
156/// BC.8c: a UTF-16 code-unit column on a given line. LSP's default
157/// position encoding counts UTF-16 code units, not bytes; converting to
158/// Lattice's byte offset (`lattice_protocol::Position.byte`) needs the
159/// target line's text, which only exists after the file is open. So
160/// [`Effect::OpenBufferAtColumn`] carries this *unconverted* column for
161/// the host to resolve post-open. Plain `u32`s — no `lsp_types` leak.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163pub struct Utf16Pos {
164    /// 0-based line.
165    pub line: u32,
166    /// 0-based column in UTF-16 code units (LSP's default encoding), not
167    /// bytes.
168    pub col: u32,
169}
170
171/// L7 (lsp-architecture.md §16): which LSP **navigation** request a
172/// mode-owned nav chord (`K` / `gd` / `gD` / `gy` / `gI` / `gr` / `gx`)
173/// wants the host to fire. Pure data — no `lsp_types`, no `lattice-lsp`
174/// dependency — so it can ride inside the host-owned [`Effect::Lsp`]
175/// boundary. `lsp-mode`'s `action_handlers()` closure returns
176/// `Effect::Lsp(LspRequest::X)`; the host's `editor.lsp_request`
177/// dispatcher maps each arm onto the existing (unchanged) async request
178/// substrate (`lsp_hover_request` / `lsp_nav_request` /
179/// `lsp_references_request` / `do_lsp_follow_link_at_cursor`). The
180/// handler carries no position — the substrate reads live `Editor`
181/// cursor/scroll, so the popup/jump anchors to the symbol the chord
182/// fired on.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum LspRequest {
185    /// `K` -- `textDocument/hover`.
186    Hover,
187    /// `gd` -- `textDocument/definition`.
188    Definition,
189    /// `gD` -- `textDocument/declaration`.
190    Declaration,
191    /// `gy` -- `textDocument/typeDefinition`.
192    TypeDefinition,
193    /// `gI` -- `textDocument/implementation`.
194    Implementation,
195    /// `gr` -- `textDocument/references`.
196    References,
197    /// `gx` -- follow the `textDocument/documentLink` covering the cursor.
198    FollowLink,
199    /// LR.2 (2026-08-11): `:lsp-references` -- the same
200    /// `textDocument/references` query as [`Self::References`], landing
201    /// at the **editable multibuffer** terminus instead of the picker.
202    ///
203    /// A data arm rather than a new `Effect` variant, per §16's grain:
204    /// a further LSP surface adds an arm here, not a host `Action`, not
205    /// a renderer classifier entry. The request, cancellation token,
206    /// anchor stash and wake are the unchanged substrate; only what the
207    /// drain does with the result differs.
208    ReferencesView,
209    /// LR.3 (2026-08-11): `gr` **inside** a references view — re-run the
210    /// query at the view's stored origin and rebuild it in place.
211    ///
212    /// Distinct from [`Self::ReferencesView`] because the position it
213    /// queries is different: this one must NOT read the live cursor.
214    /// By the time a refresh fires the cursor sits inside the
215    /// multibuffer, so querying there would ask about whatever symbol
216    /// happens to be under it — a different question with a
217    /// plausible-looking answer. The substrate reads the origin the
218    /// view recorded when it opened.
219    ReferencesViewRefresh,
220    /// EP.6 (2026-08-11): `:lsp-references-to-error-list` — the same
221    /// query, landing in the **error list** instead of a surface.
222    ///
223    /// A third terminus rather than a cache snapshot: unlike
224    /// diagnostics there is no standing "current references" state to
225    /// pull from, so the command must run the query.
226    ReferencesToErrorList,
227}
228
229/// Where in a target file an [`Effect::WriteToFile`] lands.
230///
231/// # Examples
232///
233/// ```
234/// use lattice_grammar::effect::FileAnchor;
235///
236/// // A 3-line file: append inserts before line 3 (one past the last).
237/// assert_eq!(FileAnchor::End.resolve_line(3), 3);
238/// assert_eq!(FileAnchor::Start.resolve_line(3), 0);
239/// assert_eq!(FileAnchor::Line(1).resolve_line(3), 1);
240/// // Past the end clamps to append instead of failing.
241/// assert_eq!(FileAnchor::Line(9).resolve_line(3), 3);
242/// ```
243///
244/// A **position**, not a range, and the asymmetry with [`Effect::ApplyEdit`]
245/// is the answer rather than an inconsistency to tidy away
246/// (`cross-file-writes.md` §4).
247///
248/// For its own buffer a producer holds a document handle: it can read lines,
249/// count them, and compute a range that means something. For another file it
250/// holds nothing — it has never seen the bytes, so a range it invented would
251/// be a guess. These are the three positions namable without reading, and
252/// they are exactly the three archive, refile and capture need.
253///
254/// Insert-only also bounds the blast radius: it cannot silently destroy
255/// content in a file the user was not looking at, where a range-carrying
256/// primitive could on an off-by-one and the user would find out later.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum FileAnchor {
259    /// After the last line — the common case. Archive, refile and capture all
260    /// append by default.
261    End,
262    /// Before the first line.
263    Start,
264    /// Before this 0-based line. Past the end clamps to [`FileAnchor::End`]
265    /// rather than erroring: a producer computing a line from a file it has
266    /// not read can legitimately be off, and refusing to file the text at all
267    /// is worse than filing it at the end.
268    Line(u32),
269}
270
271impl FileAnchor {
272    /// The 0-based line an insert should happen *before*, given the target's
273    /// line count.
274    ///
275    /// `line_count` is the number of content lines. `End` yields
276    /// `line_count`, i.e. one past the last — which is what "append" means to
277    /// the caller building a `Position`.
278    pub fn resolve_line(self, line_count: u32) -> u32 {
279        match self {
280            FileAnchor::Start => 0,
281            FileAnchor::End => line_count,
282            // Clamp, per the variant's doc. `>=` and not `>`: a `Line` equal
283            // to the count already means "past the last line", which is End.
284            FileAnchor::Line(n) => n.min(line_count),
285        }
286    }
287}
288
289/// What a command asks the host to do. See the [module docs](self) for
290/// the coordinate convention, which buffer an un-addressed variant acts on,
291/// and which variants are host- vs. peer-applied.
292///
293/// # Contract for producers
294///
295/// - An `Effect` is a *request*; it cannot report failure back
296///   (`apply_effect_host` returns nothing to the producer). Put anything that
297///   must happen only after a write lands *after* it in an
298///   [`Effect::Many`] -- a [`Effect::WriteToFile`] that fails stops the
299///   rest of its batch.
300/// - An evaluator that fails returns `Err` instead of an effect; nothing is
301///   committed (see [`crate::CommandError`]).
302/// - Async producers address their buffer explicitly
303///   ([`Effect::CursorMoveIn`], [`Effect::ApplyEdit`]) rather than
304///   assuming the focused one.
305///
306/// # Examples
307///
308/// ```
309/// use lattice_grammar::{Effect, EchoLevel, Register, YankKind};
310///
311/// // `yy` on "hello": yank the line, tell the user nothing.
312/// let yank = Effect::Yank {
313///     register: Register::Unnamed,
314///     content: "hello\n".into(),
315///     kind: YankKind::Linewise,
316///     explicit_yank: true,
317/// };
318///
319/// // Effects compose; the host applies `Many` children in order.
320/// let e = Effect::Many(vec![
321///     yank,
322///     Effect::Echo { level: EchoLevel::Info, text: "1 line yanked".into() },
323/// ]);
324/// assert!(!e.is_none());
325/// assert!(Effect::None.is_none());
326/// ```
327#[derive(Debug, Clone)]
328pub enum Effect {
329    /// Nothing to apply; the chord is consumed. Read-only commands and
330    /// commands whose work already happened return this. Not forwarded to
331    /// the renderer.
332    None,
333    /// AP.0.2: the action DECLINES this chord — it did nothing, and the
334    /// dispatcher should re-resolve the chord as if this action's keymap layer
335    /// weren't present, falling through to the next binding (a lower-priority
336    /// minor, the builtin/user layer, or Insert-mode self-insert). The
337    /// `with-eval-after-load` of keymaps: a plugin action (auto-pair's manual
338    /// close key / backspace) declines when it has nothing to do, so the key
339    /// still does whatever else is bound (completion nav, a normal backspace, a
340    /// user remap). Distinct from `None` (a no-op that CONSUMES the chord).
341    Declined,
342    /// Edits the grammar dispatcher has **already applied** to the focused
343    /// document (the operator ran against the document actor). The host
344    /// only routes the side effects: `DocumentChanged` (LSP `didChange`,
345    /// syntax reparse, highlight shift, dot-repeat recording). It also
346    /// moves the cursor to the first edit's `original_range.start` -- a
347    /// following [`Effect::CursorMove`] / [`Effect::SelectionChange`] in
348    /// the same [`Effect::Many`] overrides that.
349    ///
350    /// Not for code outside the dispatcher: an edit that has not been
351    /// applied yet goes through [`Effect::ApplyEdit`].
352    Edits(Vec<AppliedEdit>),
353    /// CR.0: a generic "apply this edit to this buffer" primitive.
354    ///
355    /// Mode-contributed action handlers (the snippet / lsp
356    /// `action_handlers()` pattern) compute an edit against their own
357    /// state — a diff hunk get/put, a conflict resolution — and hand it
358    /// back through this effect. The host applies `edit` to `target`,
359    /// routing through the active-document pipeline (LSP `didChange` +
360    /// syntax reparse + highlight shift) when `target` is the focused
361    /// buffer, or the peer-buffer registry handle otherwise; when
362    /// `cursor` is `Some`, it then parks the active cursor at that
363    /// **position** (line + byte) — column-precise, so a plugin action
364    /// (auto-pair) can place the caret *between* an inserted pair, not
365    /// only at a row start (AP.2). Native row-start callers pass
366    /// `Position::new(row, 0)`.
367    ///
368    /// Distinct from [`Effect::Edits`], which carries `AppliedEdit`s the
369    /// grammar dispatcher already applied to the active document (routed
370    /// only for side effects). `ApplyEdit` carries a *pending* `Edit` the
371    /// host has yet to apply, addressed at an explicit `target`.
372    ///
373    /// The Effect *vocabulary* is the host boundary by design
374    /// (`feedback_effect_vocabulary_is_host_boundary`): this lets a mode
375    /// drive an arbitrary document edit without the host growing a
376    /// feature-specific `Action` variant + `do_<x>` method per feature.
377    ///
378    /// **Deferred:** the host translates it into an `Action::ApplyEdit`
379    /// queued on the outcome's `next_actions`, so it lands after the
380    /// current effect batch has been applied, not in sequence with it.
381    ApplyEdit {
382        /// The buffer to edit. Need not be focused.
383        target: lattice_core::BufferId,
384        /// The pending edit: a half-open `(line, byte)` range in `target`'s
385        /// current (pre-edit) coordinates, plus what replaces it.
386        edit: lattice_protocol::edit::Edit,
387        /// Where to leave the **active** cursor afterwards, in post-edit
388        /// coordinates; `None` leaves it alone.
389        cursor: Option<lattice_protocol::position::Position>,
390    },
391    /// XF.1: move text into a file the editor has not necessarily opened.
392    ///
393    /// Design: [`cross-file-writes.md`](../../../docs/dev/architecture/cross-file-writes.md).
394    /// The primitive `org-archive-subtree`, `org-refile` and `org-capture`
395    /// were all blocked on — every one of them is "take text from here and
396    /// put it in a different file", and nothing in the vocabulary could say
397    /// the second half. [`Effect::ApplyEdit`] addresses a `BufferId`, which a
398    /// producer cannot learn for a file that has never been opened.
399    ///
400    /// **Through the document pipeline, not to disk.** The host resolves
401    /// `path` to a buffer, opening it in the background if needed and REUSING
402    /// it if it is already open. So an open target sees the edit, `u` covers
403    /// it, and the LSP hears about it — a direct write would leave the buffer
404    /// and the disk disagreeing with nobody told.
405    ///
406    /// **The target is left modified, not saved.** Emacs's `org-refile` and
407    /// `org-archive-subtree` both do; a plugin that silently writes files is a
408    /// larger authority than one that edits buffers, and should be an explicit
409    /// later decision if it is ever wanted.
410    WriteToFile {
411        /// Absolute, or relative to the editor's working directory.
412        ///
413        /// When this effect arrives from a plugin it has already been checked
414        /// against that plugin's `fs:write` grant AT THE BOUNDARY, where the
415        /// provenance is still known — the host's applier deliberately cannot
416        /// tell a plugin's effect from a native mode's, so the gate cannot
417        /// live there (XF.4).
418        path: std::path::PathBuf,
419        /// Where in the target the text lands.
420        anchor: FileAnchor,
421        /// Inserted verbatim. A trailing newline is the producer's business.
422        text: String,
423        /// When present, this range is removed from the buffer the action ran
424        /// in — and ONLY after the insert has landed.
425        ///
426        /// Folding the pair into one effect is what makes the bad outcome
427        /// unrepresentable: as two effects, "insert succeeded, delete failed"
428        /// duplicates the text and "delete succeeded, insert failed" LOSES
429        /// it. The second is data loss from a keystroke. An effect cannot
430        /// report failure at all (pinned by XF.0), so two ordered effects
431        /// could not be made to depend on each other without changing every
432        /// effect's signature.
433        cut: Option<lattice_protocol::position::Range>,
434        /// OR.10: create missing parent directories rather than refusing.
435        ///
436        /// **Off by default, and that default is the rule rather than caution.**
437        /// The host refuses a missing parent because creating directories is a
438        /// larger authority than creating a file, and a typo'd path must not
439        /// silently build a tree ([`crate::Effect::WriteToFile`]'s applier says
440        /// so in as many words). That stays true for every producer that does
441        /// not ask.
442        ///
443        /// A producer asks when the directory is part of the *layout it owns*
444        /// rather than something the user typed. org-roam's
445        /// `daily/YYYY-MM-DD.org` is the case that forced this: the folder is
446        /// named by an option with a default, no user ever types it, and
447        /// without this the very first `:org-roam-dailies-today` on a fresh
448        /// corpus fails — the one use where the feature has to work.
449        ///
450        /// Still bounded: a plugin's path is checked against its `fs:write`
451        /// grant at the boundary before this is read, so asking widens what is
452        /// created inside the grant and never what is reachable.
453        create_parents: bool,
454        /// OC.9: persist the target to disk once the write has landed, instead
455        /// of leaving the buffer modified.
456        ///
457        /// **Off by default, and the default is the rule.**
458        /// `cross-file-writes.md` §7 leaves a target open, listed and MODIFIED
459        /// — which is what emacs's `org-refile` and `org-archive-subtree` do,
460        /// and the user writes it themselves after reviewing. Every producer
461        /// that does not ask keeps exactly that.
462        ///
463        /// A producer asks when its whole operation *is* "commit this
464        /// somewhere". org-capture is that case, and emacs agrees loudly:
465        /// `org-capture-finalize` runs
466        /// `(unless (org-capture-get :no-save) (save-buffer))`, so saving is
467        /// the default there and `:no-save` is the opt-OUT. The asymmetry with
468        /// refile is not an inconsistency — a refile moves text you are
469        /// looking at, a capture files text you are finished with.
470        ///
471        /// It is also what decides whether readers of the FILE ever see the
472        /// write. The org agenda scans from disk, so an unsaved capture cannot
473        /// appear in a refresh however correct the buffer is.
474        ///
475        /// §7 recorded a `save: bool` as **rejected** ("the answer is uniformly
476        /// no, which is an easier thing for a user to know"). That argument was
477        /// sound for the producers that existed when it was written and wrong
478        /// for capture, whose entire contract is durability; the flag keeps the
479        /// uniform answer for everyone who does not opt in, and the design doc
480        /// records the reversal rather than dropping the paragraph.
481        ///
482        /// **Only after a landed insert**, and after any `cut`. The ordering
483        /// `cut` documents extends here: a write that failed saves nothing, so
484        /// this can never persist a half-applied effect.
485        save: bool,
486    },
487    /// Replace the focused buffer's selection set; motions return this with
488    /// a collapsed primary (`anchor == head`). The host moves the cursor to
489    /// the primary's head. In Visual / Select mode it keeps the selection
490    /// alive: a collapsed primary *extends* from the running Visual anchor
491    /// (a motion), a non-collapsed one is adopted whole (a text object
492    /// such as `viw`). Outside Visual only the cursor moves.
493    SelectionChange(SelectionSet),
494    /// Move the focused buffer's cursor to this `(line, byte)`.
495    /// The semantically-clean cursor-only jump — use this for navigation
496    /// chords (`]]`/`[[`, `]c`/`[c`, `]f`/`[f`) rather than overloading SelectionChange
497    /// with a collapsed cursor. The host writes `editor.cursor`; in Visual /
498    /// Select mode it also extends the selection from the Visual anchor to
499    /// the new position, exactly as a motion would.
500    ///
501    /// Chord-time only. An async producer must use
502    /// [`Effect::CursorMoveIn`]: by the time its result lands the focused
503    /// buffer may be a different one.
504    CursorMove(lattice_protocol::position::Position),
505    /// MG.18d: [`Effect::CursorMove`] addressed at a **specific buffer** —
506    /// the host moves the cursor only while `target` is the focused
507    /// buffer, and drops the effect otherwise.
508    ///
509    /// The peer of [`Effect::ApplyEdit`]'s `target` field, for the
510    /// cursor half. A chord-time `CursorMove` needs no target because the
511    /// buffer it fired in *is* the focused one; an **async** producer has
512    /// no such guarantee. Between a magit stage and the refresh that
513    /// finishes it lie two git calls, and a `q` or `C-^` in that window
514    /// would otherwise land the jump in whatever buffer the user moved
515    /// to — a caret teleporting in a file they were about to type in.
516    ///
517    /// Dropping (rather than stashing for the buffer's return) is
518    /// deliberate: the position was computed against content that will
519    /// have been rebuilt again by the time focus comes back, and a
520    /// stale jump is worse than none. Producers that want position
521    /// restored on return use marks / position history, which are
522    /// per-buffer by construction.
523    ///
524    /// Unlike `CursorMove` it does not touch a Visual selection.
525    CursorMoveIn {
526        /// The buffer `position` was computed against.
527        target: lattice_core::BufferId,
528        /// 0-based `(line, byte)` in `target`.
529        position: lattice_protocol::position::Position,
530    },
531    /// Write text into a register (and the host's yank ring). Every
532    /// operator that captures text emits one: yank, delete, change, `x`.
533    ///
534    /// The host always updates the unnamed register too, stores into
535    /// `register` when it names one, and ignores the whole effect for
536    /// [`Register::BlackHole`]. It moves no cursor and edits nothing.
537    Yank {
538        /// Destination register; [`Register::Unnamed`] when none was
539        /// named with `"<x>`.
540        register: Register,
541        /// The captured text, verbatim. Linewise content ends with `\n`.
542        content: String,
543        /// Shape of the capture; decides how a later put lays it out.
544        kind: YankKind,
545        /// `true` when this write came from an explicit **yank** (`y`,
546        /// `yy`, Visual `y`); `false` for the register writes that
547        /// delete / change / `x` also perform. Drives the *yank-only*
548        /// system-clipboard mirror (`clipboard.md` §5): the host mirrors
549        /// to the OS clipboard only when this is an explicit yank (under
550        /// the `clipboard` option) or the target is the `+`/`*` register,
551        /// so an incidental delete never clobbers the clipboard.
552        explicit_yank: bool,
553    },
554    /// Transition the modal state machine. Used by operators that change
555    /// modes after committing edits (vim's `c` -> Insert, future `s`,
556    /// `gv` reselect Visual, etc.).
557    EnterMode(ModalState),
558
559    // --- Ex-command effects (DESIGN.md §5.2.1) ---
560    /// `:w [path]` -- write the current buffer (to the given path, or the
561    /// document's known path).
562    ///
563    /// Host-applied (works on the off-keystroke inbound path too). In an oil
564    /// buffer it applies the listing's edits to the filesystem instead.
565    SaveBuffer {
566        /// Write-as target. A user-typed path: `~` expands and a relative
567        /// path joins the editor's `:cd` directory. `None` writes the
568        /// buffer's own file (an error echo if it has none).
569        path: Option<PathBuf>,
570    },
571    /// `:q[!]` (`scope = Pane`) / `:qa[!]` (`scope = All`) -- quit.
572    /// `force = true` ignores dirty state. The two are distinct
573    /// *commands* but one *effect*: quit is a single host operation
574    /// parameterized by scope, exactly as `force` is a parameter.
575    /// `Pane` closes the active pane when more than one is open and
576    /// only shuts the editor on the last pane (vim's `:q`); `All`
577    /// ignores pane/tab count and shuts the editor outright (vim's
578    /// `:qa`). The dirty guard (unless forced) is identical for both
579    /// and lives once in `Editor::do_quit`.
580    ///
581    /// Peer-applied.
582    QuitEditor {
583        /// `!` -- skip the dirty-buffer guard.
584        force: bool,
585        /// Pane (`:q`) or whole editor (`:qa`).
586        scope: QuitScope,
587    },
588    /// `:e[!] [path]` -- swap the current document for the file at `path`.
589    /// With `path = None` reload from the document's existing path.
590    /// `force = true` discards unsaved changes.
591    ///
592    /// Peer-applied. A file already open in another buffer is activated,
593    /// not reloaded; naming the focused buffer's own file reloads it (and
594    /// needs `force` when dirty); a directory opens a directory view.
595    OpenBuffer {
596        /// User-typed path (`~` and `:cd`-relative resolved by the host);
597        /// `None` reloads the focused document from its own path.
598        path: Option<PathBuf>,
599        /// `!` -- allow a reload that discards unsaved changes.
600        force: bool,
601    },
602    /// M.10.3 bug fix (2026-06-03): atomic "open file + position
603    /// cursor." Used by mode-contributed jump handlers (search
604    /// `<CR>`, lsp-references `<CR>`, future project-diff `<CR>`)
605    /// so the cursor lands at the matched (row, byte) inside
606    /// the newly-opened buffer on the FIRST render.
607    ///
608    /// Necessary because `Effect::SelectionChange` runs
609    /// synchronously in the host's `handle_effect` (writes to
610    /// `editor.cursor` against whatever buffer is active at
611    /// that moment), while `Effect::OpenBuffer` is renderer-
612    /// coupled — applied LATER by the TUI/GPUI peers via
613    /// `do_edit`. The two can't be ordered to land cursor on
614    /// the new buffer without an atomic step. The peer
615    /// renderer's arm for this variant calls `do_edit` THEN
616    /// `set_selections_blocking` in a single atomic block.
617    ///
618    /// Pre-fix, `<CR>` on a search hit opened the file but
619    /// landed at (0,0) because the host's SelectionChange ran
620    /// first (against the still-active multibuffer) and the
621    /// later `do_edit` reset cursor for the freshly-loaded
622    /// document.
623    OpenBufferAt {
624        /// As [`Effect::OpenBuffer`]'s `path`.
625        path: Option<PathBuf>,
626        /// Where the cursor lands in the opened buffer, 0-based
627        /// `(line, byte)`. Closed folds around it are opened.
628        position: lattice_protocol::position::Position,
629        /// As [`Effect::OpenBuffer`]'s `force`.
630        force: bool,
631        /// CD.2: text for the buffer **only when the file is not on disk**.
632        /// Reopening a file that exists never has its text replaced — a
633        /// saved draft reopened must keep what was typed into it. The seed
634        /// is an ordinary edit, so the buffer is modified and `:q` guards it.
635        content: Option<String>,
636        /// CD.2: a minor mode to activate alongside the major the path
637        /// resolves, before the buffer is shown, so its keymap is live on
638        /// the first keystroke. OC.7a's reason: a plugin mode has no
639        /// `on_activate`, so this is how a guest gives its buffer chords.
640        activate_minor: Option<String>,
641    },
642    /// LM.0: open `path` at `position` in a target pane —
643    /// split / vsplit / new tab. The peer-applied sibling of
644    /// [`Effect::OpenBufferAt`] with a pane target: its peer arm runs
645    /// `Editor::prepare_open_target_pane(target)` (the picker-accept
646    /// split sequence) then `Editor::open_buffer_at(path, position, …)`.
647    ///
648    /// `<CR>` (current pane) still returns plain [`Effect::OpenBufferAt`];
649    /// this is what the listing majors' `<C-s>`/`<C-v>`/`<C-t>` chords
650    /// return so a file lands in a new split / vsplit / tab. `target =
651    /// Default` is equivalent to `OpenBufferAt` and kept for uniformity.
652    ///
653    /// Host/peer-only for now: no WIT mirror (a plugin opening in a split
654    /// is a deliberate future WIT addition, see `boundary_effect`).
655    OpenInTarget {
656        /// As [`Effect::OpenBuffer`]'s `path`.
657        path: Option<PathBuf>,
658        /// Where the cursor lands, 0-based `(line, byte)`.
659        position: lattice_protocol::position::Position,
660        /// Which pane: current, new split, new vsplit, or new tab.
661        target: lattice_core::ui::pane::OpenTarget,
662    },
663    /// LM.2: re-list an existing oil buffer `view` to `dir` in place —
664    /// the peer-applied sibling of the directory-navigation half of the
665    /// old `do_oil_follow` / `do_oil_navigate_up`. The applier reloads
666    /// the directory snapshot (fs I/O), rewrites the buffer's rope, and
667    /// resets cursor/scroll. `focus` is the entry name to land the cursor
668    /// on afterwards (the came-from directory for `-` parent-navigation);
669    /// `None` lands at the top (a `<CR>` descent into a child).
670    ///
671    /// Names `view` explicitly so many oil buffers stay independent: the
672    /// applier touches only that buffer's dir/snapshot/rope (design §3.2).
673    /// Host/peer-only — no WIT mirror.
674    OilNavigate {
675        /// The oil buffer to re-list.
676        view: lattice_core::BufferId,
677        /// The directory it now lists.
678        dir: PathBuf,
679        /// Entry name to put the cursor on after the re-list; `None` =
680        /// first row.
681        focus: Option<String>,
682    },
683    /// LM.2: toggle the expansion of file-tree `view`'s directory entry
684    /// at `entry_index` (the row under the cursor), re-rendering the
685    /// tree's rope — the peer-applied sibling of the directory half of
686    /// `do_file_tree_follow`. Names `view` so trees stay independent.
687    /// Host/peer-only — no WIT mirror.
688    FileTreeToggle {
689        /// The file-tree buffer.
690        view: lattice_core::BufferId,
691        /// 0-based index into the tree's current entry list (the row under
692        /// the cursor). Out of range is a silent no-op.
693        entry_index: u32,
694    },
695    /// BC.8c: open `uri` via the OS handler (`open` / `xdg-open` /
696    /// `explorer`). Emitted by the LSP `window/showDocument` handler for
697    /// `external: true` requests; generic enough to reuse for any "open
698    /// this URL externally" need (`gx`, markdown / help external links).
699    /// A plain URL string — no `lsp_types` leak into the grammar.
700    /// **Host-applied** in `Editor::handle_effect`: the spawn is a host
701    /// side-effect, and the showDocument bus drains off-keystroke through
702    /// the generic inbound tick-callback (where peer-applied effects are
703    /// not forwarded), so the work must run host-side.
704    OpenExternalUri {
705        /// Any URI the OS handler accepts (`https:`, `file:`, `mailto:`).
706        /// A spawn failure is logged, not echoed.
707        uri: String,
708    },
709    /// BC.8c: **host-applied** atomic open + optional UTF-16-column
710    /// cursor placement. Unlike [`Effect::OpenBufferAt`] (peer-applied,
711    /// carrying a pre-converted byte offset), this runs entirely in
712    /// `Editor::handle_effect` so it works on the off-keystroke async
713    /// path: server-initiated `window/showDocument` drains through the
714    /// generic inbound tick-callback, where peer-applied effects are
715    /// discarded — the open must happen host-side (as the retired
716    /// `drain_inbound_show_documents` did via `do_edit`).
717    ///
718    /// `column = None` opens only, leaving the cursor where `do_edit`
719    /// puts it (the no-selection showDocument case). `Some` positions the
720    /// cursor at the UTF-16 code-unit column, converted to a byte offset
721    /// against the opened line — the conversion needs the line text, which
722    /// only exists post-open, which is why the column travels unconverted.
723    OpenBufferAtColumn {
724        /// As [`Effect::OpenBuffer`]'s `path`.
725        path: Option<PathBuf>,
726        /// Cursor target as an unconverted LSP position; `None` = open only.
727        column: Option<Utf16Pos>,
728        /// As [`Effect::OpenBuffer`]'s `force`.
729        force: bool,
730    },
731    /// I5.1 (Claude Code IDE peer): spawn a child process in a new
732    /// `BufferKind::Terminal` buffer, optionally injecting extra environment
733    /// and activating a minor mode on the new buffer. Host-applied (the open is
734    /// irreducibly `&mut Editor`): the host calls `do_terminal_spawn` with
735    /// `cmd_line` + `env`, then activates `activate_minor` (a minor mode, by its
736    /// mode-id name) on the spawned buffer when set. `:terminal` reaches the
737    /// same host path via the host-side `AppEffect::TerminalSpawn`; this grammar
738    /// variant lets crate-owned ex-commands — the IDE peer's `:claude`, which
739    /// must inject `CLAUDE_CODE_SSE_PORT` + `ENABLE_IDE_INTEGRATION` — request
740    /// the spawn through the Effect vocabulary (the host boundary) instead of a
741    /// bespoke channel.
742    SpawnTerminal {
743        /// Command line (`program [args...]`); `None` spawns `$SHELL`.
744        cmd_line: Option<String>,
745        /// PC.2: working directory to spawn in, overriding the active buffer's
746        /// project root for this spawn only.
747        ///
748        /// `lattice_terminal::SpawnConfig` has carried a `cwd` since the
749        /// terminal shipped ("`None` = inherit parent's cwd"); this is the
750        /// boundary catching up, so a producer that knows WHICH project it
751        /// means can say so. `None` keeps PR.3's behaviour exactly: spawn at
752        /// the active buffer's project root.
753        ///
754        /// Binding at spawn is what lets several projects coexist —
755        /// `Command::cwd` applies once, so a shell already running is the OS's
756        /// business and nothing resolved later can move it.
757        cwd: Option<std::path::PathBuf>,
758        /// Extra environment injected on top of the inherited parent env.
759        env: Vec<(String, String)>,
760        /// Minor mode to activate on the spawned buffer, by mode-id name (e.g.
761        /// `"claude-code-mode"`); `None` leaves the buffer mode-bare.
762        activate_minor: Option<String>,
763    },
764    /// D-fix.4 (Claude Code IDE peer): write raw bytes to the focused
765    /// terminal's PTY. Host-applied (`do_terminal_input`, irreducibly
766    /// `&mut Editor` + the terminal registry). The IDE peer's
767    /// `:claude-interrupt` emits `TerminalInput(vec![0x1b])` to forward an
768    /// `<Esc>` to the running `claude` CLI — `<Esc>` can't be sent by typing
769    /// because the terminal's modal layer consumes it for Insert→Normal, so
770    /// an ex-command is the only interrupt path. Targets the active pane's
771    /// terminal (the focused claude session); a no-op (logged) when the
772    /// active buffer isn't a terminal.
773    TerminalInput(Vec<u8>),
774    /// `:set <option>` -- the host parses the option spec; the closure
775    /// just hands the raw text through.
776    SetOption {
777        /// Everything after `:set ` (`name`, `noname`, `name=value`,
778        /// `name?`, ...). Parsed and validated by the host's config
779        /// registry; a parse error is echoed and nothing is written.
780        spec: String,
781    },
782    /// `:setlocal <option>` -- like `SetOption` but writes to the
783    /// buffer-local override layer for the active buffer only, without
784    /// touching the global config registry.
785    SetLocalOption {
786        /// Same syntax as [`Effect::SetOption`]'s `spec`.
787        spec: String,
788    },
789    /// `:setglobal <option>` -- like `SetOption` but only writes the
790    /// global config registry without updating any buffer-local override
791    /// layers. Reads back the global value on `:setglobal name?`.
792    SetGlobalOption {
793        /// Same syntax as [`Effect::SetOption`]'s `spec`.
794        spec: String,
795    },
796    /// `:noh[lsearch]` -- clear the hlsearch overlay.
797    ClearSearchHighlight,
798    /// `:colorscheme <name>` (T.9.b) -- swap the active theme by name.
799    /// The host looks `name` up in `lattice_theme::builtin_themes()`
800    /// and calls `ThemeRegistry::set_theme` (palette + override swap),
801    /// then emits `RendererSignal::ThemeChanged` so both renderers
802    /// rebuild their caches. An unknown name echoes a host-side error.
803    /// The closure only packages the name (it has no registry access).
804    SetColorscheme(String),
805    /// Display a one-line message in the echo area. Also appended to
806    /// `*messages*` and published as a message event; `Trace` / `Debug`
807    /// are recorded but not shown.
808    Echo {
809        /// Severity; decides colour and whether it is shown.
810        level: EchoLevel,
811        /// The message. Keep it to one line.
812        text: String,
813    },
814    /// L4b (lsp-architecture.md §15): show a cursor-anchored popup with
815    /// the pre-formatted diagnostic lines for the cursor's line. The
816    /// owning mode (`lsp-diagnostics-mode`) formats `lines` in its
817    /// `gl` handler; the host renders them through the hover popup
818    /// pipeline (`HelpContent` → `DisplayBufferRequest`,
819    /// `PopupPlacement::CursorAnchored`). Empty `lines` → host echoes
820    /// "no diagnostics on line" instead of an empty popup. Each line is
821    /// `(text, severity_rank)` where rank is Error = 0 … Hint = 3
822    /// (matching `lattice_lsp`'s severity_rank); the host colours each
823    /// popup line by its severity via the matching `Style::Diagnostic*`
824    /// highlight.
825    ShowDiagnosticsPopup {
826        /// `(text, severity_rank)` per popup line; rank 0 = Error, 1 =
827        /// Warning, 2 = Information, 3 = Hint.
828        lines: Vec<(String, u8)>,
829    },
830    /// L7 (lsp-architecture.md §16): fire a mode-owned LSP **navigation**
831    /// request (`K` / `gd` / `gD` / `gy` / `gI` / `gr` / `gx`). The
832    /// owning `lsp-mode` `action_handlers()` closure decides *which*
833    /// request via [`LspRequest`]; the host's `editor.lsp_request`
834    /// dispatcher runs the (unchanged) async request substrate
835    /// host-side. Host-applied — both renderers treat it as a
836    /// host-handled no-op in their effect classifiers, and it neither
837    /// mutates nor yanks. `LspRequest::FollowLink` is the one variant
838    /// that yields `RendererSignal`s synchronously (open buffer / OS
839    /// handler); the apply arm extends `out.renderer_signals`.
840    Lsp(LspRequest),
841    /// `:reg[isters]` -- the host formats and displays its own register
842    /// state.
843    EchoRegisters,
844    /// `:marks` -- the host formats and displays its own mark state.
845    EchoMarks,
846    /// `:[%]s/pat/repl/[g]` -- run substitute over the given scope, one
847    /// edit per changed line. Echoes the count, or `E486` when nothing
848    /// matched.
849    Substitute {
850        /// Which lines.
851        scope: SubstituteScope,
852        /// A `fancy_regex` pattern (Rust regex syntax plus look-around and
853        /// backreferences) -- not vim's regex dialect. Empty is an error.
854        pattern: String,
855        /// Replacement template; `$1` / `${name}` expand capture groups.
856        replacement: String,
857        /// `g` flag -- every match on a line, not just the first.
858        global: bool,
859    },
860    /// `:g/pat/body` (and `:v/pat/body` with `inverted = true`).
861    /// `body` is a pre-parsed [`CommandInvocation`] -- the parser
862    /// front-end (`lattice-host::excommand`) compiles it once at
863    /// `:g` parse time so the host doesn't re-parse per matching
864    /// line, and so body parse errors surface before `:g` fires.
865    ///
866    /// Peer-applied. The host collects the matching lines from a snapshot
867    /// first, then walks them **bottom-up**, placing the cursor at column 0
868    /// of each and dispatching `body` there, so edits never shift a line
869    /// still to be visited. No WIT mirror (`CommandInvocation` has none).
870    Global {
871        /// Matched as a **literal substring** today, unlike
872        /// [`Effect::Substitute`]'s regex. Empty is an error.
873        pattern: String,
874        /// `:v` / `:g!` -- run on the lines that do *not* match.
875        inverted: bool,
876        /// The command to run on each selected line.
877        body: Box<CommandInvocation>,
878    },
879    /// `:d` -- delete the current line including its trailing newline.
880    /// Distinct from the standard `delete` operator with a `CurrentLine`
881    /// range, which preserves the newline (vim's `dd` semantics differ
882    /// from `:d` -- §5.2.1).
883    DeleteCurrentLine,
884    /// `:describe-command <name>` (DESIGN.md §5.11). The host queries
885    /// its `CommandRegistry` for the named entry and renders the
886    /// metadata into a help overlay. Carried as a sentinel because
887    /// the closure has no registry access.
888    ///
889    /// `anchor` (optional) tells the host to scroll the help to a
890    /// named anchor after rendering -- used by the cmdline's
891    /// arg-aware `<C-h>` to land on `arg:<name>` directly.
892    DescribeCommand {
893        /// Canonical name (`ex:write`, `motion:word-forward`) or an
894        /// ex-command alias.
895        name: String,
896        /// Help anchor to scroll to, e.g. `arg:<name>`; `None` = top.
897        anchor: Option<String>,
898    },
899    /// `:describe-buffer`. The host renders a snapshot of the current
900    /// buffer's view-relevant state (path, language, modal, cursor,
901    /// dirty, line count, ...).
902    DescribeBuffer,
903    /// `:apropos <pattern>`. The host runs a substring search over
904    /// every registered `CommandSpec` (name + doc) and renders the
905    /// matches.
906    Apropos {
907        /// Case-insensitive substring matched against names and docs.
908        pattern: String,
909    },
910    /// `:describe-key <chord>` (DESIGN.md §5.11). The host queries
911    /// its keymap registry for every binding of `chord` (a chord may
912    /// appear in multiple modes -- Normal / Visual / Help, etc.) and
913    /// renders them.
914    DescribeKey {
915        /// Canonical chord notation (`<C-w>v`, `gg`), as produced by the
916        /// cmdline's chord-capture slot.
917        chord: String,
918    },
919    /// `:keymap`. The host renders the full default keymap grouped by
920    /// mode.
921    ListKeymap,
922    /// `:bn[ext]` -- cycle to the next open document buffer.
923    BufferNext,
924    /// `:bp[rev]` -- cycle to the previous open document buffer.
925    BufferPrev,
926    /// CD.1: show the buffer with this id in the active pane.
927    ///
928    /// The peer of [`Effect::ApplyEdit`]'s `target`: a plugin can edit a
929    /// buffer it knows only by id, and this is how it shows one. Opening by
930    /// path or by name cannot serve a buffer known only as an id — which is
931    /// how a capture knows the buffer it was fired from.
932    ///
933    /// **Host-applied**, so it works on the off-keystroke paths too. An id
934    /// that no longer names a buffer is a no-op logged at `debug!`: a buffer
935    /// closing between an action and its effect is an ordinary race, not a
936    /// producer bug.
937    FocusBuffer(u32),
938    /// CD.3d: run a registered command after the effects before this one.
939    ///
940    /// An action is dispatched with `args` typed; anything else runs as an ex
941    /// line. The picker's `invoke-command` outcome, as an effect.
942    ///
943    /// **Why an effect and not a host call.** A plugin's host calls run while
944    /// its action runs — before the host applies any effect the action
945    /// returns. Work that must happen only *after* a returned effect landed
946    /// (capture deletes its draft only once the entry is filed) cannot be a
947    /// host call; it goes here, after the effect it depends on. A write that
948    /// does not land stops the batch, so this does not run.
949    InvokeCommand {
950        /// A registered command name. If it names a `CommandKind::Action`
951        /// it is dispatched with `args`; otherwise `id` plus `args` is run
952        /// as an ex line.
953        id: String,
954        /// Arguments for the command (rendered onto the ex line in the
955        /// ex-line case).
956        args: crate::args::Args,
957    },
958    /// `:ls` / `:buffers` -- render every open document buffer in a
959    /// help-style view.
960    ListBuffers,
961    /// `:cd [path]` -- change the editor's working directory (what relative
962    /// paths in `:e` / `:w` resolve against). The path is user-typed: `~`
963    /// expands, relative joins the current `:cd` directory. `None` changes
964    /// to the user's home directory.
965    ChangeDir(Option<String>),
966    /// `:pwd` -- print the current working directory.
967    PrintWorkingDir,
968    /// PR.2: `:project-root` -- print the active buffer's project root
969    /// and which marker decided it. The introspection affordance for
970    /// project resolution: "why is my terminal opening here" is
971    /// otherwise only answerable by reading the source.
972    PrintProjectRoot,
973    /// `:b` with no arg -- open the vertico-style buffer switcher
974    /// (DESIGN.md §5.9.7). The user types to filter, `<CR>` to
975    /// switch. Type-aware completion in the cmdline can pre-fill
976    /// the picker via `:b <prefix>` once that wiring lands; for now
977    /// the no-arg form is the entry point.
978    OpenBufferPicker,
979    /// `:picker <source> [args...]` -- canonical picker entry
980    /// point. Dispatches by `source` against the host's
981    /// `PickerRegistry`. Unknown source ids surface as a
982    /// host-side error echo; per-source arg shape is opaque to
983    /// the grammar (the host re-parses `args` against the
984    /// resolved source's `args_schema`). Short per-source
985    /// aliases like `:files`, `:recent` emit this same effect
986    /// with the appropriate `source` set so the trait-driven
987    /// dispatch + MRU pipeline runs uniformly.
988    OpenPicker {
989        /// Registered picker-source name (`files`, `recent`, `grep`, ...).
990        source: String,
991        /// Raw whitespace-split arguments; the source re-parses them.
992        args: Vec<String>,
993        /// PC.1: root this picker resolves against, overriding the active
994        /// buffer's project for this open only.
995        ///
996        /// **Why the CONTEXT and not an argument.** A `live` source
997        /// (`grep`) re-queries through `on_query_changed`, which receives the
998        /// query and the context and NOT the open's args — and the source is a
999        /// shared `&self` generator with no per-open state. A root passed as an
1000        /// argument would therefore apply to the first query and silently
1001        /// revert to the workspace root on the next keystroke, which is worse
1002        /// than not having it. In the context it survives every re-query, and
1003        /// every root-sensitive source gets it without a per-source convention.
1004        ///
1005        /// `None` is the ordinary case: resolve from the active buffer, exactly
1006        /// as before.
1007        root: Option<std::path::PathBuf>,
1008        /// PC.11: this picker is being opened **to answer a question**, and
1009        /// the answer goes to the named command as its first argument.
1010        ///
1011        /// The picker's `FillCaller` outcome already means "hand this value
1012        /// to whoever opened me"; what it lacked was a destination a *plugin*
1013        /// can own. A guest owns none of the surfaces
1014        /// `lattice_picker::FillTarget` could name — not the document, not
1015        /// the `:` line, not a prompt — but it does own an ex-command, so
1016        /// that becomes the destination. [`Effect::OpenPrompt`]'s
1017        /// `on_submit_action` is the same shape for the same reason, and the
1018        /// asymmetry between the two (a guest could be handed a prompt's
1019        /// answer but not a picker's) is what this closes.
1020        ///
1021        /// **Not a flag that overrides the source's accept.** The source
1022        /// decides what accepting one of ITS candidates means; `file-pick`
1023        /// and `dir-pick` exist as separate sources precisely so that "supply
1024        /// a value" is a source's own decision rather than a caller's
1025        /// override. This names where a `FillCaller` lands, which is a
1026        /// question `FillTarget` already owns.
1027        ///
1028        /// `None` leaves the existing behaviour untouched: the target is
1029        /// whatever surface was captured at open.
1030        fill_action: Option<String>,
1031        /// CD.6a: text the picker's query starts with. For a static source it
1032        /// narrows the rows from the first frame, as emacs's `completing-read`
1033        /// initial input does; org-roam's node insert seeds it from the active
1034        /// region. `None` is an empty prompt (or a live source's own seed).
1035        query: Option<String>,
1036    },
1037    /// `:bd[elete][!]` -- close the active document buffer.
1038    /// `force = true` discards unsaved changes.
1039    BufferDelete {
1040        /// `!` -- close even if modified.
1041        force: bool,
1042    },
1043    /// `:Tree [path]` -- open a file-tree buffer rooted at `path`.
1044    /// Absent = the document's parent directory.
1045    OpenFileTree {
1046        /// Directory to root the tree at.
1047        root: Option<PathBuf>,
1048    },
1049    /// `:TreeClose` -- dismiss the file-tree buffer.
1050    CloseFileTree,
1051    /// `:Oil [path]` -- open an oil buffer for `path` (flat editable listing).
1052    /// Absent = current document's parent directory / cwd.
1053    OpenOil {
1054        /// Directory to list.
1055        dir: Option<PathBuf>,
1056    },
1057    /// `:describe-option NAME` -- render the option's metadata in
1058    /// a help view.
1059    DescribeOption {
1060        /// Option name as used with `:set`.
1061        name: String,
1062    },
1063    /// `:describe-element NAME` / `:describe-face NAME` (T.9.d) --
1064    /// render a registered theme element's metadata in a help view:
1065    /// owner, doc, the authoring (reference-form) `StyleSpec` default
1066    /// (palette keys + inherit parent), and the concrete resolved
1067    /// `Style`. The introspection counterpart of `:describe-option` /
1068    /// `:describe-mode` for theme elements. The host reads the
1069    /// `ThemeRegistry::describe` snapshot; an unknown name echoes an
1070    /// error.
1071    DescribeElement {
1072        /// Theme element name (`modeline.mode`, `diagnostic.error`, ...).
1073        name: String,
1074    },
1075    /// `:options` -- list every registered option.
1076    ListOptions,
1077    /// `:describe-plugin-api [<seam>]` (PI.2) -- render the plugin-API
1078    /// catalog. With `seam` = an interface name (`host-services`,
1079    /// `picker-source`, ...), render that one interface's functions +
1080    /// direction + capability. Without, render the same as
1081    /// `:list-plugin-apis`. The catalog is derived from `wit/` at build
1082    /// time (`lattice-plugin-api`); the host holds no plugin runtime.
1083    DescribePluginApi {
1084        /// WIT interface name; `None` lists every interface.
1085        seam: Option<String>,
1086    },
1087    /// `:list-plugin-apis` (PI.2) -- list every plugin-API interface the
1088    /// `wit/` package exposes, one row per interface (name + direction +
1089    /// capability + function count).
1090    ListPluginApis,
1091    /// `:export-plugin-api [markdown|json]` (PI.2b) -- dump the whole
1092    /// plugin-API catalog into a savable synthetic buffer (`*plugin-api.md*`
1093    /// / `*plugin-api.json*`) the author saves with `:w <path>`. `format`
1094    /// defaults to markdown; `json` selects the machine-readable form. The
1095    /// host owns the dump + buffer open (the `OpenSyntheticBuffer` pattern).
1096    ExportPluginApi {
1097        /// `"markdown"` (the default when `None`) or `"json"`.
1098        format: Option<String>,
1099    },
1100    /// `:list-commands` (PI.3) -- enumerate every registered command grouped
1101    /// by source layer (built-in / user config / plugin / ...). The one
1102    /// introspection enumeration the help family was missing; a plugin group
1103    /// resolves the plugin id to its manifest name where the host knows it.
1104    ListCommands,
1105    /// `:describe-plugin <name>` (PI.4, Facet B) -- render one loaded plugin's
1106    /// own documentation + contributions. The doc comes from the plugin
1107    /// (embedded WIT world doc / manifest `doc`), resolved once at load. Unknown
1108    /// / no-plugin-loaded echoes an error. Loaded-plugin enumeration is
1109    /// Phase-8-gated; the surface + registry seam exist now.
1110    DescribePlugin {
1111        /// The plugin's manifest name.
1112        name: String,
1113    },
1114    /// `:list-plugins` (PI.4) -- list every loaded plugin (name + doc summary).
1115    /// Empty until the Phase-8 loader populates the registry.
1116    ListPlugins,
1117    /// `:hover [text]` -- open a hover popup at the cursor with
1118    /// `text` as the markdown body. A manual / testing entry point; LSP
1119    /// hover (`K`) goes through [`Effect::Lsp`] instead.
1120    OpenHover {
1121        /// Markdown body of the popup.
1122        markdown: String,
1123    },
1124    /// Dismiss the active popup, whatever its content. Content-agnostic
1125    /// (routes through `dismiss_popup`); produced today by `:HoverClose`.
1126    ///
1127    /// **This is the USER's verb — "close what I am looking at".** It is the
1128    /// right one for a key the user pressed (`:popup-dismiss`, magit's `q`,
1129    /// answering a permission prompt), because the popup they mean is the one
1130    /// on screen by definition. It is the WRONG one for a mode dismissing on
1131    /// its own schedule — see [`Effect::DismissPopupNamed`].
1132    DismissPopup,
1133    /// Dismiss the active popup **only if it is the named one** — a no-op
1134    /// otherwise.
1135    ///
1136    /// `name` is the popup buffer's synthetic name, the same string
1137    /// [`Effect::OpenPopup`] was given.
1138    ///
1139    /// **Why the pair exists.** There is one popup slot
1140    /// (`Editor::popup_buffer`), so [`Effect::DismissPopup`] means "close
1141    /// whatever is in it". For a keypress that is exactly right. For a
1142    /// BACKGROUND dismissal it is a bug waiting to be written: between the
1143    /// moment a mode decided to close its popup and the moment the effect is
1144    /// applied, the slot may hold someone else's — and the mode closes that
1145    /// instead, silently, with nothing in the type to warn it.
1146    ///
1147    /// Which-key wrote that bug. Its timer-driven dismissal fired on every
1148    /// resolved chord, so any two-key sequence typed faster than the popup
1149    /// delay (`zz`, `gg`, `dd`, `ci"`) tore down whatever hover or diagnostic
1150    /// popup happened to be showing. Naming the target makes the stale case a
1151    /// no-op instead of a wrong action.
1152    ///
1153    /// **Rule of thumb:** a dismissal the user asked for is
1154    /// [`Effect::DismissPopup`]; a dismissal a mode decided on is this.
1155    DismissPopupNamed {
1156        /// The popup buffer's synthetic name.
1157        name: String,
1158    },
1159    /// Show a popup overlay at `placement` with the given `focus`
1160    /// (popup-api.md §4.3). Content-agnostic and data-only: the host
1161    /// idempotently ensures a popup buffer named `name` under major mode
1162    /// `mode_id` (`Editor::open_popup_named`) and the owning mode's
1163    /// `on_activate` projects the content. Name-based, not id-based, because
1164    /// the emitters (the `:ai-permission` ex-command, the async tick callback)
1165    /// have no host access to register a buffer and supply a `BufferId` — a
1166    /// name keeps the effect vocabulary the host boundary, like
1167    /// `OpenSyntheticBuffer`.
1168    OpenPopup {
1169        /// Synthetic name of the popup buffer; reused if it exists. Also
1170        /// the key [`Effect::DismissPopupNamed`] matches on.
1171        name: String,
1172        /// Major mode for the buffer, by mode-id string; must be registered.
1173        mode_id: String,
1174        /// Where the popup floats (cursor-anchored, centred, ...).
1175        placement: lattice_core::ui::popup::PopupPlacement,
1176        /// Whether the popup takes keyboard focus.
1177        focus: lattice_core::ui::popup::PopupFocus,
1178    },
1179    /// `:help [topic]` -- open a free-form help topic. With no
1180    /// topic the host renders the index (`docs/user/README.md`
1181    /// equivalent); with a topic the host looks it up in its
1182    /// help-topic registry and surfaces the body in a help
1183    /// buffer. Unknown topics echo an error.
1184    OpenHelpTopic {
1185        /// Topic name; `None` opens the index.
1186        topic: Option<String>,
1187    },
1188
1189    /// `:diagnostics` -- render every workspace diagnostic in a
1190    /// help-style buffer with clickable per-entry source links
1191    /// (Phase 4.1.d.iv). The host queries its
1192    /// `LspSupervisor::diagnostics()` layer and formats.
1193    ListDiagnostics,
1194    /// CM.8: `:clist` / `:cl` — open the error list in a fuzzy
1195    /// picker (the flat browse-and-jump surface, parallel to
1196    /// `:diagnostics`). Complements `:cnext` (step) and `:copen` (the
1197    /// `*problems*` multibuffer). Host builds it from `Editor::error_list`.
1198    ListErrors,
1199    /// `]d` / `:diag-next` -- move the cursor to the
1200    /// next diagnostic in the active buffer. Wraps to top.
1201    NextDiagnostic,
1202    /// `[d` / `:diag-prev` / `:cprev` -- move the cursor to the
1203    /// previous diagnostic in the active buffer. Wraps to
1204    /// bottom.
1205    PrevDiagnostic,
1206
1207    /// `:lsp-log [server]` (Phase 4.1.g) -- open the subsystem
1208    /// log buffer (`*lsp*`) when `server_id` is None, or the
1209    /// per-server log (`*lsp:<server>*`) when set.
1210    OpenLspLog {
1211        /// Server id (as in `:lsp-status`); `None` = the subsystem log.
1212        server_id: Option<String>,
1213    },
1214    /// `:ai-log [provider]` (AI-1b) -- open the per-session AI log
1215    /// buffer (`*ai:<provider>:<index>*`). With no known session,
1216    /// echoes an info hint; with exactly one, opens it directly; with
1217    /// more (optionally narrowed by the `session` provider
1218    /// prefilter), raises a picker. Peer-applied via the host's
1219    /// `do_open_ai_log`, exactly like [`Effect::OpenLspLog`]. The
1220    /// `lattice-ai` crate owns the `:ai-log` binding + this
1221    /// emission; the host owns only the generic
1222    /// `ensure_named_synthetic_document` + `AiLogMode` open.
1223    OpenAiLog {
1224        /// Provider-prefix filter over known sessions; `None` = all.
1225        session: Option<String>,
1226    },
1227    /// Open (or focus) a named synthetic buffer under a given major mode --
1228    /// the generic primitive behind provider-owned buffer views (e.g. the
1229    /// `ai-conversation` `*ai:opencode*` buffer). The emitter (a mode's
1230    /// command handler) supplies the buffer name + the mode id; the host owns
1231    /// only the generic `ensure_named_synthetic_document` open, so no
1232    /// provider-specific host method is added. `mode_id` is the mode's string
1233    /// id (`ModeId::new(&mode_id)`); the mode must be registered at boot.
1234    ///
1235    /// OC.7a adds `content` / `cursor` / `activate_minor`. A native mode fills
1236    /// its own buffer from `on_activate`; the `modes` WIT seam is
1237    /// declaration-only, so a PLUGIN mode has no such hook and a guest emitting
1238    /// this got a buffer it could never put text in. `Effect::ApplyEdit` is not
1239    /// the way out either — it names a `target` buffer id this effect does not
1240    /// hand back. All three are `None` for every pre-OC.7a emitter.
1241    OpenSyntheticBuffer {
1242        /// Buffer name (`*ai:opencode*`); an existing buffer of that name is
1243        /// focused rather than recreated.
1244        name: String,
1245        /// Major mode id; must be registered at boot.
1246        mode_id: String,
1247        /// Seed text, applied BEFORE the buffer is shown so the first frame is
1248        /// the finished one. Ignored when the buffer already existed — a
1249        /// re-open must not overwrite what the user has typed, which is the
1250        /// difference between reopening a capture and losing one.
1251        content: Option<String>,
1252        /// Where to leave the caret in `content` (org capture's `%?`).
1253        /// Out-of-range is clamped, not refused: a template whose `%?` sits
1254        /// past its own text is a template bug that must not cost the capture.
1255        cursor: Option<lattice_protocol::position::Position>,
1256        /// A minor to activate alongside the major. Mirrors
1257        /// `Effect::SpawnTerminal`'s `activate_minor` and exists for the same
1258        /// reason — the interesting behaviour rides a general-purpose major.
1259        activate_minor: Option<String>,
1260    },
1261    /// MG.50: [`Effect::OpenSyntheticBuffer`] + cursor placement, in one
1262    /// step.
1263    ///
1264    /// The synthetic peer of [`Effect::OpenBufferAt`], and it exists for
1265    /// exactly the same reason: the open is peer-applied while a cursor
1266    /// effect runs host-side against whatever buffer is active at that
1267    /// moment, so `Many([open, move])` cannot land the caret on a buffer
1268    /// that does not exist yet. Emitted by magit's `<CR>`, which opens a
1269    /// staged blob at the line the cursor was reading in the diff.
1270    OpenSyntheticBufferAt {
1271        /// As [`Effect::OpenSyntheticBuffer`]'s `name`.
1272        name: String,
1273        /// As [`Effect::OpenSyntheticBuffer`]'s `mode_id`.
1274        mode_id: String,
1275        /// Cursor target in the opened buffer, 0-based `(line, byte)`.
1276        position: lattice_protocol::position::Position,
1277    },
1278    /// `:messages` -- open the `*messages*` buffer (the emacs
1279    /// `*Messages*` analogue). Renders a chronological view
1280    /// of every echo / minibuffer notification; live-tails as
1281    /// new entries arrive via the typed event bus.
1282    OpenMessages,
1283    /// `:dashboard` -- open (or re-compose + activate) the
1284    /// `*dashboard*` launch page. The applier reads config, composes
1285    /// the enabled sections via the crate-owned `DashboardRegistry`
1286    /// service, and seeds a read-only `BufferKind::Dashboard` buffer.
1287    /// See `docs/dev/architecture/dashboard.md` §9.
1288    OpenDashboard,
1289    /// `:lsp-trace <server>` -- pure toggle of JSON-RPC tracing
1290    /// for the server. The trace buffer is opened separately via
1291    /// `:lsp-trace-log <server>` so peeking mid-stream doesn't
1292    /// flip the toggle off.
1293    ToggleLspTrace {
1294        /// Server id (as in `:lsp-status`).
1295        server_id: String,
1296    },
1297    /// `:lsp-trace-log [server]` -- open the JSON-RPC trace ring
1298    /// (`*lsp:<server>:trace*`) in the active pane via the
1299    /// vertico picker (Phase 3). No arg = picker over every
1300    /// running instance; arg = pre-filter; single match short-
1301    /// circuits the picker. Independent of the trace toggle.
1302    OpenLspTraceLog {
1303        /// Server-id filter; `None` = pick among all running servers.
1304        server_id: Option<String>,
1305    },
1306    /// `:lsp-status` -- render every running server (id, root,
1307    /// pid, uptime, capability summary) in a help-style buffer.
1308    LspStatus,
1309    /// EP.4 (2026-08-10): `:lsp-diagnostics-to-error-list` -- pull the
1310    /// current published diagnostics into the error list's `Lsp` slice
1311    /// on demand.
1312    ///
1313    /// The manual peer of the live feed gated by
1314    /// `lsp.diagnostics-to-error-list`. Useful when that option is off,
1315    /// and as a forced refresh after a server restart when it is on.
1316    /// Echoes the entry count, because this surfaces what servers have
1317    /// *published* -- not a workspace scan -- and an empty result must
1318    /// not be misread as a clean tree.
1319    LspDiagnosticsToErrorList,
1320    /// `:lsp-server-log` -- picker-style listing of every running
1321    /// server actor with workspace root + buffer count +
1322    /// capability summary, each row carrying `exec:` links to
1323    /// the per-server log + trace buffers. Use vim search
1324    /// (`/query`) to filter rows; press `<CR>` on a link to
1325    /// open. A real fuzzy picker arrives with the bundled
1326    /// fuzzy-finder plugin (Phase 8b).
1327    LspServerLogListing,
1328    /// `:lsp-restart <server>` -- ask the LSP supervisor to restart the
1329    /// server. Runs asynchronously on the LSP runtime; the outcome is
1330    /// written to the LSP log.
1331    LspRestart {
1332        /// Server id (as in `:lsp-status`).
1333        server_id: String,
1334    },
1335    /// `:lsp-progress-cancel [server]` -- send
1336    /// `window/workDoneProgress/cancel` for every active,
1337    /// cancellable progress entry on the named server (or, with
1338    /// no arg, on every server currently attached to the active
1339    /// buffer). Non-cancellable entries are left alone — the
1340    /// host's cancel is best-effort regardless. 4.4.c.
1341    LspProgressCancel {
1342        /// Server id; `None` = every server attached to the active buffer.
1343        server_id: Option<String>,
1344    },
1345    /// 4.4.e: `:lsp-expand-region` -- structural smart-
1346    /// expansion. First invocation fires `textDocument/selectionRange`
1347    /// at the cursor; each subsequent invocation walks one
1348    /// `parent` step outward in the cached chain. Enters Visual
1349    /// mode with the resolved range as the selection.
1350    LspExpandRegion,
1351    /// 4.4.e: `:lsp-shrink-region` -- inverse walk through the
1352    /// cached selection-range chain. No-op when the chain is
1353    /// empty / at the innermost step.
1354    LspShrinkRegion,
1355    /// `:lsp-log-level [server] <level>` -- set the subsystem-
1356    /// wide default min level (when `server_id` is None) or a
1357    /// per-server override.
1358    SetLspLogLevel {
1359        /// Server id; `None` = the subsystem-wide default.
1360        server_id: Option<String>,
1361        /// `trace`, `debug`, `info`, `warn` / `warning` or `error`; anything
1362        /// else is echoed as an error.
1363        level: String,
1364    },
1365    /// `:lsp-log-clear [server]` -- drop the ring's records.
1366    /// `None` clears the subsystem-wide ring; a server id
1367    /// clears that ring.
1368    LspLogClear {
1369        /// Server id; `None` = the subsystem-wide ring.
1370        server_id: Option<String>,
1371    },
1372    /// `:lsp-symbols` -- open a picker over the active document's
1373    /// LSP symbol outline (`textDocument/documentSymbol`). Phase
1374    /// 4.2.e.
1375    LspDocumentSymbol,
1376    /// `:lsp-workspace-symbol [query]` -- open a picker over
1377    /// workspace-scoped symbols matching `query` (server-side
1378    /// substring filter). Phase 4.2.f.
1379    LspWorkspaceSymbol {
1380        /// Query sent to the server; empty asks for everything.
1381        query: String,
1382    },
1383    /// `:lsp-incoming-calls` -- 4.5.a. Prepares call-hierarchy
1384    /// items at the cursor, fans out `callHierarchy/incomingCalls`
1385    /// for the first item, opens the merged caller list as a
1386    /// vertico picker. "Who calls this function?"
1387    LspIncomingCalls,
1388    /// `:lsp-outgoing-calls` -- 4.5.a. Symmetric peer of
1389    /// `LspIncomingCalls`. "What does this function call?"
1390    LspOutgoingCalls,
1391    /// `:lsp-supertypes` -- 4.5.b. Same shape as
1392    /// `LspIncomingCalls` but for type relationships: prepares
1393    /// type-hierarchy items, fans out
1394    /// `typeHierarchy/supertypes`, opens the picker. "What
1395    /// does this type subtype?"
1396    LspSupertypes,
1397    /// `:lsp-subtypes` -- 4.5.b. Symmetric peer of
1398    /// `LspSupertypes`. "What subtypes this type?"
1399    LspSubtypes,
1400    /// `:lsp-moniker` -- 4.5.g. Fires `textDocument/moniker`
1401    /// at the cursor; echoes the resulting moniker list
1402    /// (scheme + identifier + unique level + optional kind).
1403    /// Useful for indexers (SCIP / LSIF) + cross-repo
1404    /// navigation; the result surfaces as a one-line
1405    /// summary, not a picker.
1406    LspMoniker,
1407    /// `:lsp-code-lens` -- 4.5.d. Open a picker over the
1408    /// cached `textDocument/codeLens` entries for the active
1409    /// buffer. Accept routes the chosen lens's `command`
1410    /// through `workspace/executeCommand` (after a lazy
1411    /// `codeLens/resolve` if the lens arrived without a
1412    /// command).
1413    LspCodeLens,
1414    /// `:lsp-color-presentation` -- 4.5.e. At the cursor,
1415    /// look up the color literal in the per-buffer
1416    /// `documentColor` cache and fire
1417    /// `textDocument/colorPresentation` to fetch alternative
1418    /// formats (named, rgb(), hex, etc.). Open a picker;
1419    /// accept splices the chosen alternative.
1420    LspColorPresentation,
1421    /// `:lsp-format` -- run `textDocument/formatting` on the highest-
1422    /// priority server with `documentFormattingProvider` and
1423    /// apply the returned edits as one undo unit. Phase 4.3.
1424    LspFormat,
1425    /// IN.8b: `:format` -- format the whole buffer through the
1426    /// availability cascade (LSP if a server advertises formatting,
1427    /// otherwise `formatprg` or the built-in per-language table).
1428    ///
1429    /// Distinct from `LspFormat`, which is LSP-only by name and stays
1430    /// that way. `:format` is the LSP-INDEPENDENT command, which is
1431    /// why its generic name satisfies the ex-command naming rule
1432    /// rather than violating it: that rule exists because a generic
1433    /// name implies "works regardless of LSP", and this one does.
1434    Format,
1435    /// `:lsp-format-range` -- run `textDocument/rangeFormatting`
1436    /// over the active Visual selection (when in Visual mode)
1437    /// or the supplied line range. Apply edits atomically.
1438    /// Phase 4.3.
1439    LspFormatRange,
1440    /// `:lsp-signature-help` (or trigger-character driven). Send
1441    /// `textDocument/signatureHelp` to attached servers; first
1442    /// non-empty response renders into the hover popup.
1443    LspSignatureHelp,
1444    /// `:lsp-complete` -- fire `textDocument/completion` at the
1445    /// cursor and open the merged item list as a vertico
1446    /// picker. Phase 4.2.g.
1447    LspComplete,
1448    /// `:lsp-rename <new-name>` -- run textDocument/prepareRename
1449    /// (when advertised) then textDocument/rename; apply the
1450    /// returned WorkspaceEdit as one undo unit across every
1451    /// affected buffer. Phase 4.3.
1452    LspRename {
1453        /// The new identifier.
1454        new_name: String,
1455    },
1456    /// `:lsp-code-action` -- run textDocument/codeAction at the
1457    /// cursor / selection; open the merged item list as a
1458    /// vertico picker. Accept routes through resolve (when
1459    /// needed) and applies the action's WorkspaceEdit /
1460    /// command. Phase 4.3.
1461    LspCodeAction,
1462    /// SN.3c.1: direct snippet expansion over a known trigger
1463    /// range. The mode-owned `<C-x><C-s>` handler
1464    /// (`snippet-mode`'s `action_handlers()`) does the word-prefix
1465    /// scan and emits this with `replace_range = token-start..cursor`;
1466    /// the **host** owns resolution + expansion (language detection,
1467    /// registry lookup, variable render, buffer splice + session
1468    /// install) via `Editor::expand_snippet_from_range`. A deliberate
1469    /// first-party effect: the typed `Effect` enum stays a host-owned
1470    /// vocabulary (`feedback_effect_vocabulary_is_host_boundary`) —
1471    /// the mode owns the *trigger*, the host owns the *expansion
1472    /// mechanics*. No-op (quiet info echo) when no snippet matches the
1473    /// prefix.
1474    ExpandSnippet {
1475        /// The trigger text to replace, half-open `(line, byte)` range on a
1476        /// single line of the focused buffer; its text is the prefix looked
1477        /// up (active language first, then `*`).
1478        replace_range: lattice_protocol::position::Range,
1479    },
1480    /// `:reload-snippets` -- re-read every snippet file from
1481    /// disk and rebuild the per-language registry (Phase
1482    /// 4.2.g.4). Useful after editing a `.code-snippets` /
1483    /// `.json` file in the project's snippet directory.
1484    ReloadSnippets,
1485
1486    /// `:describe-events` -- render a help buffer listing every
1487    /// registered event (M.5.3.c). Walks
1488    /// `lattice_protocol::event_registry::EVENT_DESCRIPTORS` and
1489    /// formats each as `name :: source-crate :: doc`.
1490    DescribeEvents,
1491    /// `:describe-diff` -- render a help buffer listing every
1492    /// active diff session (D.2.d). Walks the host's
1493    /// `DiffSubsystem::describe_sessions` and formats each row
1494    /// as `BufferId | Algorithm | Rev | Hunks | Watches`.
1495    DescribeDiff,
1496    /// `:diff` (no args) -- open an inline diff session for
1497    /// the active document against its on-disk content.
1498    /// D.3.a.1.
1499    DiffOpen,
1500    /// `:diffoff[!]` -- close the active pane's diff session
1501    /// (if any). v1 two-way semantics collapse `:diffoff` and
1502    /// `:diffoff!` to the same teardown (removing one side of
1503    /// a two-way diff leaves the other degenerate, so both
1504    /// drop the whole session). The bang is a forward-compat
1505    /// surface: D.6 (three-way merge) will distinguish per-
1506    /// participant removal (`:diffoff`) from full-session
1507    /// teardown (`:diffoff!`). The handler reads the session's
1508    /// watch list as the source of truth for participants —
1509    /// the tab is not a grouping unit. D.3.a.1 / D.4.d.3.a.
1510    DiffOff {
1511        /// `!`; currently identical to the plain form (see above).
1512        force: bool,
1513    },
1514    /// `:diffthis` -- stage the active pane for a two-pane
1515    /// diff; the second `:diffthis` invocation in a different
1516    /// pane completes the session (creates a `DiffSession`
1517    /// against the two live buffers, a `PaneGroup` with
1518    /// `HunkRowMapper`, and `FillerRowProvider`s on each
1519    /// side). Same pane twice unstages. Third-pane staging
1520    /// errors out — v1 is two-way only; multi-way arrives
1521    /// with D.6 three-way merge. D.4.d.3.a.
1522    Diffthis,
1523    /// `:diffsplit <file> [<remote>]` -- open `<file>` (and
1524    /// optionally `<remote>`) in new vertical splits and
1525    /// register a diff session between the current pane and
1526    /// the new pane(s).
1527    ///
1528    /// - **One arg** (`:diffsplit base`): two-way diff
1529    ///   between the current pane (current side) and a new
1530    ///   pane loading `base` (baseline side). D.4.d.3.b.
1531    /// - **Two args** (`:diffsplit base remote`): three-way
1532    ///   merge with the current pane as "local", a new pane
1533    ///   loading `base` as the common ancestor, and a third
1534    ///   new pane loading `remote` as the other side. D.6.c.
1535    ///
1536    /// Composes vsplit + `:edit <path>` in each new pane +
1537    /// the appropriate registration helper
1538    /// (`register_two_pane_diff` for one arg,
1539    /// `register_three_pane_diff` for two). Empty first arg
1540    /// errors at parse time. Cursor lands in the *first*
1541    /// new pane (vim parity).
1542    Diffsplit {
1543        /// The baseline (two-way) or common-ancestor (three-way) file.
1544        path: std::path::PathBuf,
1545        /// The "other side" for a three-way merge; `None` = two-way.
1546        remote: Option<std::path::PathBuf>,
1547    },
1548    /// `:diffget [<bufnr>]` -- pull the hunk under the cursor
1549    /// from the named (or auto-resolved) buffer side. D.6.d.
1550    /// `target` is the optional buffer number passed by the
1551    /// user; `None` means "the peer side" (two-way: unique;
1552    /// three-way: ambiguous — dispatch emits "target required").
1553    /// The chord-driven `do` operator stays unit-variant
1554    /// `Action::DiffGet`; this ex-command variant is a parallel
1555    /// entry point for explicit-target invocations.
1556    DiffGetCmd {
1557        /// Buffer number to pull from; `None` = the unique peer side.
1558        target: Option<u32>,
1559    },
1560    /// `:diffput [<bufnr>]` -- push the hunk under the cursor
1561    /// into the named (or auto-resolved) buffer side. D.6.d.
1562    /// Mirror of [`Self::DiffGetCmd`] but for the put direction.
1563    DiffPutCmd {
1564        /// Buffer number to push into; `None` = the unique peer side.
1565        target: Option<u32>,
1566    },
1567    /// `:diff-accept` -- resolve the active pane's diff
1568    /// session with `lattice_diff::DiffOutcome::Accept`. v1 semantics:
1569    /// equivalent to `:diffoff` + signal Accept on the
1570    /// session's completion channel (if any). The buffer's
1571    /// current content (whatever the user applied via
1572    /// `do`/`dp` or left alone) becomes the accepted
1573    /// resolution; plugins consuming the outcome commit
1574    /// from there. D.6.e.
1575    DiffAccept,
1576    /// `:diff-reject` -- resolve the active pane's diff
1577    /// session with `lattice_diff::DiffOutcome::Reject`. v1 semantics:
1578    /// equivalent to `:diffoff!` + signal Reject. Plugins
1579    /// consuming the outcome should revert any
1580    /// pre-session state. D.6.e.
1581    DiffReject,
1582    /// `:diff-accept-all` — resolve EVERY pending review (each session with a
1583    /// bound completion) with `lattice_diff::DiffOutcome::Accept`. The bulk counterpart to
1584    /// `:diff-accept` for when several agent reviews are open at once.
1585    DiffAcceptAll,
1586    /// `:diff-reject-all` — resolve EVERY pending review with
1587    /// `lattice_diff::DiffOutcome::Reject`. Bulk counterpart to `:diff-reject`.
1588    DiffRejectAll,
1589    /// D-fix.6: an IDE-peer connection's `close_tab` — tear down (as a
1590    /// Reject) every *programmatic* diff session that THIS connection
1591    /// (`origin_session`) opened, regardless of how/where it is
1592    /// displayed. Host-applied: it fires each matching session's bound
1593    /// completion oneshot with `lattice_diff::DiffOutcome::Reject` and closes its
1594    /// panes (the `:diff-reject` teardown, but targeted by
1595    /// `origin_session` rather than the active pane). If the connection
1596    /// opened no diff, the host falls back to closing the active buffer
1597    /// when `tab_name` matches its path (the legacy I3 file-close — the
1598    /// only remaining `tab_name` use, orthogonal to the diff teardown).
1599    CloseSessionDiffs {
1600        /// The originating connection id; only diffs tagged with it are
1601        /// torn down (`0` = none — a non-IDE producer's diff is never
1602        /// matched). Cross-session isolation: connection A's close can
1603        /// never affect connection B's diffs.
1604        origin_session: u64,
1605        /// The agent's close-tab label, used ONLY for the legacy
1606        /// active-buffer file-close fallback when no diff matched.
1607        tab_name: String,
1608    },
1609    /// D-fix.6: an IDE-peer connection's `closeAllDiffTabs` — tear down
1610    /// (as a Reject) every programmatic diff session `origin_session`
1611    /// opened. Same scoping as [`Self::CloseSessionDiffs`] but with no
1612    /// file-close fallback (it is unambiguously a diff-only bulk close).
1613    CloseAllSessionDiffs {
1614        /// The originating connection id; scopes the bulk teardown.
1615        origin_session: u64,
1616    },
1617    /// `]c` / `:hunk-next` -- jump cursor to the start of the
1618    /// next diff hunk on the current side (`ranges[1]`).
1619    /// Wraps to top. D.3.c.
1620    NextHunk,
1621    /// `[c` / `:hunk-prev` -- jump cursor to the start of the
1622    /// previous diff hunk on the current side. Wraps to
1623    /// bottom. D.3.c.
1624    PrevHunk,
1625    /// `:describe-event <name>` -- render the descriptor for a
1626    /// single registered event (M.5.3.c). The introspection
1627    /// counterpart of `:describe-command` for events.
1628    DescribeEvent {
1629        /// Event name as listed by `:describe-events`.
1630        name: String,
1631    },
1632
1633    /// `:list-modes` -- render every registered mode in a help
1634    /// buffer (M.8). Groups by kind (Major / Minor); each row
1635    /// shows the mode's id and current activation state on the
1636    /// active buffer. The mode counterpart of `:options`.
1637    ListModes,
1638    /// `:describe-mode <name>` -- render one mode's metadata
1639    /// (M.8): id, kind, contributed option overrides,
1640    /// required capabilities, and current activation state on
1641    /// the active buffer. The introspection counterpart of
1642    /// `:describe-command` / `:describe-option` /
1643    /// `:describe-event` for modes.
1644    DescribeMode {
1645        /// Mode id (`rust-mode`, `magit-core-mode`).
1646        name: String,
1647    },
1648    /// `:describe-active-modes` (`<C-h>m`) -- render the mode
1649    /// stack live on the *active* buffer: the major plus every
1650    /// minor, each with the chords it contributes.
1651    ///
1652    /// Distinct from [`Effect::DescribeMode`], which describes
1653    /// one *named* mode whether or not it is active, and from
1654    /// [`Effect::ListModes`], which lists every *registered*
1655    /// mode. This one answers "what is this buffer, and what
1656    /// can I press in it".
1657    ///
1658    /// Major-only would under-report by construction: the
1659    /// minor-mode convention deliberately pushes chords shared
1660    /// across majors out into a minor (magit's `gr` / `q` /
1661    /// `]]` live on `magit-core-mode`, not on each magit
1662    /// major), so the view is major + minors.
1663    ///
1664    /// An additive variant rather than widening
1665    /// `DescribeMode`'s `name` to `Option<String>` — the WIT
1666    /// declaration is `describe-mode(string)` and widening it
1667    /// would break a published plugin API.
1668    DescribeActiveModes,
1669    /// `:describe-bindings` (`<C-h>K`) -- the chords that can
1670    /// actually fire on the *active* buffer: builtin entries live in
1671    /// the current binding-mode, plus every active mode's
1672    /// contributions.
1673    ///
1674    /// Distinct from [`Effect::ListKeymap`] (`:keymap`), which
1675    /// renders the whole static catalog regardless of what is
1676    /// active. `:keymap` stays the exhaustive reference; this one
1677    /// answers "what can I press *here*".
1678    DescribeActiveBindings,
1679    /// `:describe-option-resolution <name>` -- show which
1680    /// resolver layer (modal / buffer-local / mode
1681    /// contribution / typed-option / default) provides the
1682    /// resolved value for `<name>` on the active buffer
1683    /// (M.8). Helps debug surprising values when a mode's
1684    /// contribution shadows a `:set` write or vice versa.
1685    DescribeOptionResolution {
1686        /// Option name as used with `:set`.
1687        name: String,
1688    },
1689
1690    /// `:customize [name]` -- open the customize buffer
1691    /// (M.9). With no arg, opens the group + mode picker.
1692    /// With an arg ending in `-mode`, opens the focused
1693    /// view of that mode's contributed options. Otherwise,
1694    /// opens the cross-mode group view (every option in the
1695    /// named group, sectioned by owning mode).
1696    ///
1697    /// M.9.0 ships the read-only listing form. M.9.1 wires
1698    /// per-row navigation + Enter-to-edit; for now edits run
1699    /// via the existing `:set` machinery on the cmdline.
1700    Customize {
1701        /// Group or `*-mode` name; `None` = the picker.
1702        name: Option<String>,
1703    },
1704    /// `:tutor [N]` -- open the interactive tutor lesson `N`
1705    /// (default: 1) in a fresh editable buffer. The lesson
1706    /// content is embedded in the binary and copied to a
1707    /// temp file each time so the user starts fresh and can
1708    /// practice motions / operators on the file itself
1709    /// (vim-tutor pattern). Lessons are the
1710    /// `docs/user/tutor/lesson-N.md` files embedded by the host.
1711    Tutor {
1712        /// 1-based lesson number; `None` = lesson 1.
1713        lesson: Option<u32>,
1714    },
1715    /// `:<mode-name>` -- toggle a registered mode on the active
1716    /// buffer (M.5.1; mode-architecture §9.6.1). For minors:
1717    /// activate if inactive, deactivate if active. For majors:
1718    /// activate if not currently the major; reload (deactivate
1719    /// then re-activate) if it's already the active major. Mode
1720    /// resolution is by name, not id object, because the grammar
1721    /// crate stays renderer-agnostic and doesn't depend on
1722    /// `lattice-mode`.
1723    ToggleMode {
1724        /// Mode id string, e.g. `"auto-pair-mode"`.
1725        mode_name: String,
1726    },
1727
1728    /// Free-form App-side effect produced by a `CommandKind::Action`
1729    /// dispatch. Carries an [`AppEffect`] -- the typed App-side
1730    /// counterpart to the dispatcher-native variants above. The
1731    /// host's `apply_effect` matches the inner `AppEffect` to drive
1732    /// chord-bound work that has no grammar concept attached
1733    /// (`<Esc>` exits Visual, `<C-w>v` splits a pane, `o` opens a
1734    /// line below). Slice 8.i wires this surface; see
1735    /// `docs/dev/notes/8i-approach.md`.
1736    AppAction(AppEffect),
1737
1738    /// M.10.3 (2026-06-03): record the editor's CURRENT cursor +
1739    /// active buffer onto the position-history ring as an
1740    /// `AutoJump` entry — vim's jump-list semantics for "big
1741    /// motions" (gg, G, /, *, mark jumps, ...). Used by
1742    /// mode-contributed jump actions (search `<CR>`,
1743    /// lsp-references `<CR>`, future project-diff `<CR>`) so
1744    /// `<C-o>` walks the user back to where they were before
1745    /// the jump. Must be the FIRST sub-effect inside an
1746    /// `Effect::Many` that also opens a new buffer — the host
1747    /// reads the cursor/active-buffer state at apply time, so
1748    /// after `OpenBuffer` lands the recorded entry would be the
1749    /// new doc's start, not the pre-jump location.
1750    RecordJump,
1751
1752    /// Open a yes/no confirmation dialog. The host shows a transient
1753    /// picker with `prompt` as the title; `y` dispatches `yes_action`
1754    /// with `args`, `n` / `q` / Esc dismisses.
1755    ///
1756    /// **IX.1: `args` is what makes the confirmed thing and the executed
1757    /// thing the same thing.** Without it a yes-half has to re-derive
1758    /// its target when it fires, and the context it derives from is not
1759    /// stable across the wait — a background refresh can rebuild the
1760    /// buffer and move the cursor while the dialog is up, so the action
1761    /// lands on a different target than the prompt named. Carrying the
1762    /// target closes that window by construction.
1763    ///
1764    /// Carry the **payload, not a pointer to it**: a path, a SHA, a
1765    /// synthesized patch — not a cursor row or a row span, which a
1766    /// rebuild invalidates. For patch-shaped payloads this also makes
1767    /// `git apply`'s context check refuse a stale one loudly instead of
1768    /// applying it somewhere plausible.
1769    ///
1770    /// `Args::None` keeps the pre-IX.1 behaviour (the yes-half
1771    /// re-derives), so existing confirms are unaffected until migrated.
1772    ///
1773    /// **Why a name + `Args` rather than a `CommandInvocation`,** which
1774    /// is otherwise the canonical "thing to execute" (design §5.2.1):
1775    /// this effect has to cross the plugin seam, and `CommandInvocation`
1776    /// has no WIT mirror — it is exactly why [`Effect::Global`] fails at
1777    /// the boundary. `Args` is mirrored and a name is a string, so this
1778    /// payload crosses. A name is also the plugin-native form: plugins
1779    /// register actions by name and cannot hold a host `CommandId`.
1780    Confirm {
1781        /// The question shown as the dialog title.
1782        prompt: String,
1783        /// Registered action name dispatched on `y`.
1784        yes_action: String,
1785        /// Arguments passed to `yes_action` (see above).
1786        args: crate::Args,
1787    },
1788
1789    /// Return the active pane to the buffer it was displaying before a
1790    /// full-pane synthetic buffer took it over, and drop that buffer's
1791    /// hold on the pane ("bury", in vim's sense — the buffer stays in
1792    /// the registry, it just stops being shown).
1793    ///
1794    /// Distinct from [`Effect::DismissPopup`] on purpose. A popup
1795    /// *floats over* the pane: the underlying document is never
1796    /// swapped out, so dismissing one only has to drop the overlay.
1797    /// A synthetic buffer opened full-pane (magit's views, oil, the
1798    /// plugin manager) genuinely replaced the pane's buffer AND the
1799    /// editor's active-document handle, so returning has to swap both
1800    /// back. magit's `q` used `DismissPopup` for this and left the
1801    /// active document pointing at magit while the pane pointed at the
1802    /// file — the pane said one thing and the screen painted another.
1803    ///
1804    /// A no-op when nothing was buried (no origin to return to), so a
1805    /// mode can bind it unconditionally.
1806    BuryBuffer,
1807
1808    /// [`Effect::BuryBuffer`], then delete the buffer that was buried:
1809    /// the pane returns to where it came from and the buffer is gone.
1810    ///
1811    /// For a buffer whose life ends at a verb — a compose buffer on
1812    /// finish or cancel (magit's commit, note and rebase-todo buffers;
1813    /// with-editor's `C-c C-c` / `C-c C-k`). Bury alone keeps the buffer,
1814    /// and a synthetic buffer reopened by name is reused WITHOUT being
1815    /// re-seeded, so the next commit came back holding the previous
1816    /// message.
1817    ///
1818    /// No dirty check: the emitting mode has decided the buffer is done.
1819    /// With no origin to return to it behaves as `:bd!`.
1820    KillBuffer,
1821
1822    /// Open a named transient picker menu. `source` is a name
1823    /// registered into a `TransientSourceRegistry` (`lattice-picker`)
1824    /// by the owning mode crate at boot — mirrors `OpenPicker`'s
1825    /// named-source shape. The registry, not this enum, holds the
1826    /// actual `TransientSpec` builder, since `TransientSpec` lives in
1827    /// a crate downstream of `lattice-grammar`.
1828    /// TR.3a: `args` are the arguments this open was requested with;
1829    /// they reach the builder as `TransientContext::args`.
1830    ///
1831    /// Without them a menu cannot drill down — a row that opens a second
1832    /// menu has no way to say what it opened it FOR. Org's capture menu
1833    /// has a row per template and the fields menu it opens must know
1834    /// which template it is collecting for; the only alternative is guest
1835    /// memory, which `<Esc>` never clears, so the next open would inherit
1836    /// the last one's subject.
1837    ///
1838    /// `Args::None` is a plain open, which is every native menu today.
1839    OpenTransient {
1840        /// Registered transient-source name.
1841        source: String,
1842        /// Context for the menu builder (see above).
1843        args: crate::args::Args,
1844    },
1845
1846    /// Open a one-line minibuffer text prompt. `prompt` is shown as
1847    /// an info-level echo label; `initial` pre-seeds the input
1848    /// buffer's content; `on_submit_action` names a registered
1849    /// `action:*` handler fired on `<CR>` with the typed text
1850    /// available as `ActionContext::prompt_value` (never a closure —
1851    /// same name-based-lookup convention as `Confirm`'s `yes_action`
1852    /// and `OpenTransient`'s `source`, so the variant stays a plain,
1853    /// serializable value with no crate carrying a callback type
1854    /// downstream of `lattice-grammar`). `buffer_name`, when set,
1855    /// becomes the synthetic prompt buffer's name — callers use this
1856    /// to stash context for the submit handler to read back (mirrors
1857    /// how magit's blame/rebase/revision modes encode their target in
1858    /// the buffer name); `None` uses a default unnamed prompt buffer.
1859    /// `<Esc>` cancels without firing anything.
1860    OpenPrompt {
1861        /// Label shown for the prompt.
1862        prompt: String,
1863        /// Initial input text; may be empty.
1864        initial: String,
1865        /// Registered `action:*` name run on `<CR>`.
1866        on_submit_action: String,
1867        /// Name for the prompt buffer (context for the submit handler);
1868        /// `None` = default.
1869        buffer_name: Option<String>,
1870    },
1871
1872    /// Several effects, applied **in order** (the host flattens nested
1873    /// `Many`). Host-applied children all run before any peer-applied
1874    /// child. A [`Effect::WriteToFile`] that fails stops the remainder;
1875    /// nothing else does. Order matters: [`Effect::RecordJump`] must precede
1876    /// the open it records, and a cursor effect must follow the edit it
1877    /// positions after.
1878    Many(Vec<Effect>),
1879}
1880
1881impl Effect {
1882    /// `true` only for [`Effect::None`]. An empty [`Effect::Many`] is not
1883    /// `None`, nor is [`Effect::Declined`].
1884    pub fn is_none(&self) -> bool {
1885        matches!(self, Effect::None)
1886    }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    #![allow(clippy::unwrap_used, clippy::panic)]
1892    use super::*;
1893
1894    // ── XF.1: the anchor resolver ──────────────────────────────────────
1895    //
1896    // The whole of `FileAnchor`'s logic, and the reason it is a position
1897    // rather than a range: a producer addressing a file it has never read can
1898    // name these three and nothing else (`cross-file-writes.md` §4).
1899
1900    #[test]
1901    fn end_resolves_one_past_the_last_line() {
1902        // "Append" means insert *before* line `line_count` — one past the
1903        // last — which is what a caller building a `Position` needs.
1904        assert_eq!(FileAnchor::End.resolve_line(3), 3);
1905    }
1906
1907    /// An empty target is the capture case: the file is created by this very
1908    /// write, so every anchor has to agree on line 0 rather than one of them
1909    /// producing an out-of-range insert into a document with no lines.
1910    #[test]
1911    fn every_anchor_agrees_on_an_empty_file() {
1912        assert_eq!(FileAnchor::End.resolve_line(0), 0);
1913        assert_eq!(FileAnchor::Start.resolve_line(0), 0);
1914        assert_eq!(FileAnchor::Line(0).resolve_line(0), 0);
1915        assert_eq!(FileAnchor::Line(7).resolve_line(0), 0);
1916    }
1917
1918    #[test]
1919    fn start_is_always_the_first_line() {
1920        assert_eq!(FileAnchor::Start.resolve_line(0), 0);
1921        assert_eq!(FileAnchor::Start.resolve_line(100), 0);
1922    }
1923
1924    #[test]
1925    fn a_line_in_range_resolves_to_itself() {
1926        assert_eq!(FileAnchor::Line(0).resolve_line(5), 0);
1927        assert_eq!(FileAnchor::Line(3).resolve_line(5), 3);
1928    }
1929
1930    /// Past the end CLAMPS rather than erroring. A producer computing a line
1931    /// from a file it has not read can legitimately be off, and refusing to
1932    /// file the text at all is worse than filing it at the end — this is the
1933    /// difference between "your archive entry went somewhere" and "your
1934    /// archive entry is gone".
1935    #[test]
1936    fn a_line_past_the_end_clamps_to_append() {
1937        assert_eq!(FileAnchor::Line(9).resolve_line(5), 5);
1938        assert_eq!(FileAnchor::Line(u32::MAX).resolve_line(5), 5);
1939        assert_eq!(
1940            FileAnchor::Line(9).resolve_line(5),
1941            FileAnchor::End.resolve_line(5),
1942            "clamping means exactly End, not almost-End"
1943        );
1944    }
1945
1946    /// `Line(n)` where n == the count is already "past the last line", so it
1947    /// is End rather than an off-by-one that inserts inside the last line.
1948    #[test]
1949    fn a_line_equal_to_the_count_is_append_not_the_last_line() {
1950        assert_eq!(
1951            FileAnchor::Line(5).resolve_line(5),
1952            FileAnchor::End.resolve_line(5)
1953        );
1954    }
1955
1956    /// `WriteToFile` is a mutation for dot-repeat and for Visual auto-exit.
1957    /// Pinned here because both classifiers live in other crates and a new
1958    /// effect silently defaulting to "not a mutation" is the kind of gap
1959    /// nothing surfaces until `.` mysteriously replays the wrong thing.
1960    #[test]
1961    fn write_to_file_is_constructible_with_and_without_a_cut() {
1962        let base = Effect::WriteToFile {
1963            path: std::path::PathBuf::from("/tmp/archive.org"),
1964            anchor: FileAnchor::End,
1965            text: "* Done\n".to_string(),
1966            cut: None,
1967            create_parents: false,
1968            save: false,
1969        };
1970        assert!(matches!(base, Effect::WriteToFile { cut: None, .. }));
1971
1972        let moving = Effect::WriteToFile {
1973            path: std::path::PathBuf::from("/tmp/archive.org"),
1974            anchor: FileAnchor::End,
1975            text: "* Done\n".to_string(),
1976            cut: Some(lattice_protocol::position::Range::new(
1977                lattice_protocol::position::Position::new(2, 0),
1978                lattice_protocol::position::Position::new(5, 0),
1979            )),
1980            create_parents: false,
1981            save: false,
1982        };
1983        assert!(matches!(moving, Effect::WriteToFile { cut: Some(_), .. }));
1984    }
1985
1986    /// OC.9: `save` is a per-call decision, not a property of the effect —
1987    /// capture asks for it and refile / archive do not, from the same
1988    /// constructor. Pinned because the whole point of the flag over a blanket
1989    /// host-side save is that two producers can disagree.
1990    #[test]
1991    fn write_to_file_carries_the_callers_save_choice() {
1992        let committing = Effect::WriteToFile {
1993            path: std::path::PathBuf::from("/tmp/inbox.org"),
1994            anchor: FileAnchor::End,
1995            text: "* TODO Captured\n".to_string(),
1996            cut: None,
1997            create_parents: false,
1998            save: true,
1999        };
2000        assert!(matches!(committing, Effect::WriteToFile { save: true, .. }));
2001    }
2002
2003    #[test]
2004    fn none_is_none() {
2005        assert!(Effect::None.is_none());
2006    }
2007
2008    #[test]
2009    fn yank_carries_register_and_content() {
2010        let e = Effect::Yank {
2011            register: Register::Unnamed,
2012            content: "hello".into(),
2013            kind: YankKind::Charwise,
2014            explicit_yank: true,
2015        };
2016        match e {
2017            Effect::Yank {
2018                register,
2019                content,
2020                kind,
2021                explicit_yank,
2022            } => {
2023                assert_eq!(register, Register::Unnamed);
2024                assert_eq!(content, "hello");
2025                assert_eq!(kind, YankKind::Charwise);
2026                assert!(explicit_yank);
2027            }
2028            _ => panic!("expected Yank"),
2029        }
2030    }
2031
2032    #[test]
2033    fn yank_kind_serializes() {
2034        let charwise = serde_json::to_string(&YankKind::Charwise).unwrap();
2035        let linewise = serde_json::to_string(&YankKind::Linewise).unwrap();
2036        assert!(charwise.contains("Charwise"));
2037        assert!(linewise.contains("Linewise"));
2038    }
2039}