Skip to main content

lattice_ui_tui/app/
dispatch.rs

1//! The dispatch core -- where typed input becomes state mutation.
2//!
3//! Three layers cooperate here:
4//!
5//! 1. `App::apply(action)` -- the main `Action` dispatcher fired
6//!    from the runtime input loop and from macro replay. Handles
7//!    macro-recording capture, partial-chord lifecycle, the
8//!    help-buffer read-only guard, and a fat match arm that
9//!    routes every `Action` variant to its feature method.
10//! 2. `App::run_invocation(inv)` -- routes a typed
11//!    `CommandInvocation` to the right per-buffer-kind handler
12//!    (`run_oil_invocation` / `run_file_tree_invocation` /
13//!    `run_help_invocation` / `run_document_invocation`).
14//! 3. `App::apply_effect(effect)` -- consumes the
15//!    grammar/dispatcher's `Effect` and translates it into App
16//!    side effects (edits, mode flips, save/quit, set-option,
17//!    LSP requests, picker opens, ...).
18//!
19//! Plus the supporting cast: `dispatch_blocking` (the sync
20//! wrapper over the document actor's grammar dispatch),
21//! `apply_app_effect` (the `AppEffect` sub-dispatcher),
22//! `handle_edits` (the `Effect::Edits` applier), and
23//! `execute_ex_line` (the `:` submit dispatcher).
24//!
25//! Plus a few private helpers used only here:
26//! - `effect_mutates` / `effect_mutates_or_yanks` -- the
27//!   Effect classifiers used by the dot-repeat record gate
28//!   and the Visual-mode auto-exit gate.
29//! - `delete_trailing_word` (`<C-w>` cmdline word delete).
30//! - `action_is_document_mutation` (the help-buffer
31//!   read-only guard's allow-list inversion). Lives in
32//!   `lattice_host::dispatch` since 5.5.D.
33//! - `echo_level_from_grammar` (grammar-level → host-level
34//!   echo-level translator). Lives in `lattice_host::dispatch`
35//!   since 5.5.E.1, beside its sole caller (`Effect::Echo`).
36//! - `COMMAND_HISTORY_CAP` (the `:`-history capacity).
37//!
38//! What does NOT live here: the per-feature `do_*` methods the
39//! match arms call. Those live in their feature modules; this
40//! is the routing layer over them.
41
42use lattice_grammar::command::CommandInvocation;
43use lattice_grammar::effect::Effect;
44use lattice_host::dispatch::RendererSignal;
45use lattice_runtime::RuntimeError;
46
47use super::{Action, App, BufferKind, EchoLevel};
48use crate::excommand;
49
50const COMMAND_HISTORY_CAP: usize = 100;
51
52/// Trim the last whitespace-delimited word from the end of `s`.
53/// `<C-w>` semantics on the command line: removes the partial token
54/// the user is typing (plus any trailing spaces). v1 cursor is
55/// always at end-of-line; if cursor support lands later this should
56/// take a cursor offset and operate to the left of it.
57fn delete_trailing_word(s: &mut String) {
58    // Strip trailing whitespace.
59    let trimmed = s.trim_end_matches(char::is_whitespace);
60    if trimmed.len() < s.len() {
61        s.truncate(trimmed.len());
62    }
63    // Strip the trailing non-whitespace run.
64    let last_ws = s.rfind(char::is_whitespace);
65    let cut_to = last_ws.map(|i| i + 1).unwrap_or(0);
66    s.truncate(cut_to);
67}
68
69// 5.5.D: `action_is_document_mutation` lives in
70// [`lattice_host::dispatch::action_is_document_mutation`] alongside
71// the read-only-help guard that consults it. Any ui-tui-side reader
72// would import it from the host crate; today nothing else in this
73// module needs it.
74
75impl App {
76    /// Block_on a grammar dispatch through the actor (DESIGN.md
77    /// §5.2.1). Replaces direct `lattice_grammar::execute(&self.editor.registry,
78    /// &mut self.editor.document, ...)` calls; the actor holds the only
79    /// `&mut Document` and runs `execute` inside its task.
80    ///
81    /// v1 passes a `CancellationToken::never()` -- the input loop
82    /// (`lattice_ui_tui::runtime::run`) is single-threaded crossterm
83    /// poll, so no concurrent code path can flip the token while
84    /// `block_on` parks the thread. The plumbing is in place for a
85    /// future runtime that reads input on a separate task and flips
86    /// the dispatch token on Esc; see `dispatch_with_cancel` on
87    /// `RopeDocumentHandle`.
88    /// 5.5.G.23: body migrated to
89    /// [`lattice_host::dispatch::Editor::dispatch_blocking`]. Retained
90    /// as a 1-line delegate while App-side helpers
91    /// (`run_oil_invocation` / `run_read_only_motion` /
92    /// `run_document_invocation`) are still hosted here.
93    pub fn dispatch_blocking(&self, invocation: CommandInvocation) -> Result<Effect, RuntimeError> {
94        self.read_editor(move |e| e.dispatch_blocking(invocation))
95    }
96
97    /// Returns `true` when the resolved binding was a grammar action that
98    /// **declined** (`Effect::Declined`) — it did nothing, and the caller
99    /// holding the originating `KeyChord` (the TUI live loop) should re-resolve
100    /// the chord at the next keymap layer (AP.0.2 fall-through). The host
101    /// `dispatch_chord` path (GPUI / tests) does this itself; the TUI's
102    /// translate→apply path lost the chord by the time the decline is known, so
103    /// `apply` surfaces the flag and `runtime.rs` performs the re-resolve.
104    pub fn apply(&mut self, action: Action) -> bool {
105        // Phase 5.5.B–D: macro-recording capture, partial-chord
106        // lifecycle, and the read-only-help guard live in
107        // `Editor::dispatch`'s preamble. When `outcome.consumed` is
108        // set the host already surfaced the relevant echo + cleanup
109        // (read-only-help today); App's match below must bail.
110        //
111        // 5.5.G.final / 5.5.H: ~90 Action arms now resolve host-side
112        // via `Editor::dispatch` (every motion / operator / text-object
113        // / ex-command keystroke; `Invoke`, `Insert`, macros, dot-
114        // repeat, find-repeat, the full 8-arm command-line cluster).
115        // `Effect::AppAction` collapses through `Editor::apply_app_effect`
116        // (5.5.G.24); `out.next_actions` is the unified deferred-Action
117        // channel for everything host-emitted that needs the renderer's
118        // `apply` loop (LSP autopilots, macro replay, AppEffect-derived
119        // follow-ups).
120        //
121        // The 13 explicit arms remaining are the architectural seam,
122        // NOT migration debt:
123        //
124        //   - `LspOnTypeFormattingRequest(c)` /
125        //     `LspInsertCompletionRequest` — host→renderer LSP autopilot
126        //     landing zones. The host fires the action; the renderer
127        //     owns the LSP runtime (`spawn_on_lsp_runtime`, BatchingSink,
128        //     `pending_insert_completion_lsp_*`). gpui will implement
129        //     its own equivalent on `lattice-ui-gpui::App`.
130        //
131        //   - 8 Completion arms (`CompletionTrigger`/`Next`/`Prev`/`Accept`/
132        //     `ToggleDocs`/`FilterToSource`/`FilterClear`/`AcceptThenInsert`)
133        //     — completion popup state mutation + LSP async resolve.
134        //     `editor.completion` lives host-side, but the LSP fetch
135        //     path is renderer-coupled (same reason as above).
136        //
137        //   - `PickerAccept` / `PickerDismiss` — file-open + the
138        //     show-message-request queue. `do_edit` is renderer-side
139        //     because the gpui port opens a window, not a TUI pane.
140        //
141        //   - `LspFollowLinkAtCursor` / `FollowLink` / `OilNavigateUp`
142        //     — reach for `do_edit` + `open_external_uri` + the
143        //     BufferKind-dispatch help/oil/file-tree helpers, each of
144        //     which terminates in a renderer-coupled chokepoint.
145        //
146        // `consumed` collapses when (if) every subsystem above migrates
147        // host-side; until then the seam is correct, not debt. The
148        // gpui peer renderer implements its own version of these arms
149        // against its own LSP runtime / window-open / picker UI.
150        // Pre-dispatch state the tail below still reads (the
151        // ensure-cursor-visible skip on popup close).
152        //
153        // The hover auto-dismiss used to capture `pre_cursor` and
154        // `pre_popup` here too. It does not any more: that state machine is
155        // the host's, in `Editor::dispatch`, where its own pre-dispatch
156        // capture also supplies the guard this comment used to describe — a
157        // popup opened BY this action was not there before it, so it cannot
158        // be dismissed by its own opening cycle.
159        let pre_active = self.ad().buffer_kind;
160        let pre_popup_focused = self.ad().popup_focused;
161        // Slice 3c.final.E.5d: pre-clone `action` so the closure
162        // can own its copy (needed for `Send + 'static`) while
163        // the outer `match action {}` at the tail still has access.
164        let action_for_dispatch = action.clone();
165        // Slice I.7: collapse the keystroke's actor round-trips. Before
166        // I.7 the keystroke path cost SIX blocking actor crossings —
167        // `completion_popup_active` (translate ctx) + `dispatch` + the
168        // four-op tail (`ensure_cursor_visible` / `maybe_reparse_syntax` /
169        // `sync_keymap_overlays` / `run_tick_pending`), each a separate
170        // `mutate_editor*` mailbox crossing (~0.5ms on WSL2 → the ~3.5ms
171        // felt typing lag). `dispatch_fused` runs `dispatch` PLUS the
172        // whole tail inside ONE round-trip whenever the dispatch produced
173        // no renderer-coupled work and no popup is up (the hot typing
174        // path). `popup_up` is a published read (no RPC); when a popup is
175        // shown we decline the fuse so the App-side State-A hover-dismiss
176        // machine below runs unchanged. See
177        // docs/dev/operations/slice-plans/input-latency.md § I.7.
178        let popup_up = self.popup().buffer_id.is_some();
179        let pre_popup_focused_for_closure = pre_popup_focused;
180        let pre_active_for_closure = pre_active;
181        let fused = self.mutate_editor_with(move |e| {
182            e.dispatch_fused(
183                action_for_dispatch,
184                pre_active_for_closure,
185                pre_popup_focused_for_closure,
186                popup_up,
187            )
188        });
189        let mut outcome = fused.outcome;
190        // AP.0.2: a declining plugin grammar action sets `outcome.declined`
191        // (empty effects, so it always fuses). Surface it to the caller — the
192        // TUI live loop re-resolves the same chord with no minor-mode layers.
193        let declined = outcome.declined;
194        if let Some(tail_signals) = fused.tail_signals {
195            // FUSED FAST PATH: the deterministic tail already ran in-actor
196            // (ensure_cursor_visible / maybe_reparse_syntax /
197            // sync_keymap_overlays / run_tick_pending). The `match action`
198            // body below is a grouped no-op for every fuseable (host-
199            // handled) action, and State-A hover-dismiss is impossible
200            // here (the fuse requires no popup), so the only work left is
201            // surfacing the tick signals (e.g. `ThemeChanged`) UI-side.
202            for signal in tail_signals {
203                self.handle_renderer_signal(signal);
204            }
205            return declined;
206        }
207        // LEGACY MULTI-RPC PATH: the dispatch carried renderer-coupled
208        // effects / signals / next_actions, or `consumed` the action, or a
209        // popup is up. The App-side drains + tail below preserve the
210        // original ordering exactly (effects' `do_*` handlers must run
211        // before the tail so e.g. `OpenBufferAt`'s active-doc switch lands
212        // before `ensure_cursor_visible` clamps).
213        // 5.5.G.23: drain any effects the host queued (e.g. from
214        // host-side `Editor::run_invocation` producing
215        // `Effect::SaveBuffer` / `Effect::OpenBuffer` / etc.). The host
216        // has already called `editor.handle_effect(effect.clone())` on
217        // each one, so we drain via `apply_effect_app_arms` — the
218        // renderer-coupled match alone, never `handle_effect` again.
219        // Drained BEFORE the `consumed` early-return so host-handled
220        // actions still get their renderer-side effect tail. Drained
221        // BEFORE `renderer_signals` so signal handlers (e.g. theme
222        // rebuild) observe the final state.
223        for effect in std::mem::take(&mut outcome.effects) {
224            self.apply_effect_app_arms(effect);
225        }
226        // Signals queued on the editor rather than returned. Two
227        // producers: `impl ModeActivator for Editor` (its trait surface
228        // returns `()`, so `create_multibuffer_view` and friends stash
229        // them), and `Editor::activate_buffer`, which completes every
230        // activation internally and so cannot hand its cascade back
231        // through a `bool`. Drained here, beside the outcome's own, so
232        // neither producer can be forgotten by a caller.
233        for signal in self.mutate_editor_with(|e| e.drain_pending_renderer_signals()) {
234            self.handle_renderer_signal(signal);
235        }
236        for signal in std::mem::take(&mut outcome.renderer_signals) {
237            self.handle_renderer_signal(signal);
238        }
239        // 5.5.G.23.insert: drain follow-up Actions the host queued
240        // (LSP autopilots like `LspOnTypeFormattingRequest` /
241        // `LspInsertCompletionRequest` that need `spawn_on_lsp_runtime`
242        // App-side; macro replay via `editor.do_play_macro` emits the
243        // recorded action list here). Each runs through the full
244        // `apply` loop so it sees the same macro-recording /
245        // partial-chord lifecycle as user-driven actions. The
246        // `should_quit` break preserves the mid-macro-quit semantic:
247        // a recorded `:q` short-circuits the rest of the replay so
248        // we don't keep firing actions against a tearing-down App.
249        for follow_up in std::mem::take(&mut outcome.next_actions) {
250            self.apply(follow_up);
251            if self.render_state.load().lifecycle.should_quit {
252                break;
253            }
254        }
255        if outcome.consumed {
256            // A consumed action was handled host-side — never a fall-through.
257            return false;
258        }
259        // M.4 hover-popup unification: gate the auto-dismiss-on-
260        // doc-cursor-motion behaviour on `hover-mode` being active
261        // on the popup buffer, instead of the structural
262        // `prev_pane_for_popup.is_none()` check. State A (popup
263        // shown, doc focused) is "hover-mode active + active is
264        // Document"; State B (focused popup) is "active is Help"
265        // -- the second clause stays as the State-A discriminator.
266        // Slice 3c.final.X.cleanup: read via published `popup()`
267        // (popup_buffer) + `modes()` (ModesRenderState, B.11). Per-
268        // keystroke hot path — App::apply runs on every input — so
269        // dropping the actor RPC saves ~94µs per keystroke. The
270        // two-step chain stays atomic because both reads come off
271        // the same RS snapshot (RenderState publishes are atomic
272        // via ArcSwap).
273        match action {
274            // SN.3c.2b: a fall-through binding resolves to a sequence
275            // (mode action, then native). Apply each sub-action through
276            // the full apply path so renderer-level sub-actions
277            // (completion, etc.) and editor Invokes both route
278            // correctly.
279            Action::Chain(actions) => {
280                for a in actions {
281                    self.apply(a);
282                }
283            }
284            // Phase 5.5.C: helper-free arms moved to
285            // `Editor::dispatch`'s match. Grouped no-op here keeps
286            // the exhaustiveness check satisfied without splitting
287            // App's match logic. 5.5.G eventually collapses this
288            // whole function; until then the grouped pattern is the
289            // seam.
290            Action::None
291            | Action::Quit
292            | Action::AbsorbPartialChord(_)
293            | Action::PushDigit(_)
294            | Action::Echo(_)
295            | Action::CommandLineCancel
296            | Action::CommandLineToggleExpand
297            | Action::SelectRegister(_)
298            // Host-handled: `consume_transient_key` fires the row, holds the
299            // prefix, or drops it. The App has nothing to add.
300            | Action::TransientKey(_)
301            | Action::CommandLineDismissCompletion
302            | Action::EnterSearch(_)
303            // 5.5.G.1: pure-editor fold / macro / snippet arms.
304            // Bodies migrated to `Editor::dispatch`; this match
305            // routes them through the grouped no-op above.
306            | Action::OpenFoldAtCursor
307            | Action::CloseFoldAtCursor
308            | Action::ToggleFoldAtCursor
309            | Action::OpenAllFolds
310            | Action::CloseAllFolds
311            | Action::CycleFoldAtCursor
312            | Action::CycleFoldsGlobal
313            | Action::DeleteFoldAtCursor
314            | Action::OpenFoldsRecursively
315            | Action::CloseFoldsRecursively
316            | Action::DeleteFoldsRecursively
317            | Action::GotoNextFold
318            | Action::GotoPrevFold
319            | Action::GotoParentFold
320            | Action::StartMacroRecord(_)
321            | Action::StopMacroRecord
322            // SN.3c.2 (2026-06-14): `Action::SnippetLeave` removed —
323            // `<Esc>` is mode-owned (`active-snippet-mode` handler).
324            // 5.5.G.2: pure-editor visual + mark arms migrated to
325            // `Editor::dispatch`.
326            | Action::EnterVisual(_)
327            | Action::ExitVisual
328            // SN.3d: Select-mode arms are pure-editor (handled in
329            // `Editor::dispatch`); grouped no-op here like their Visual
330            // peers.
331            | Action::EnterSelect(_)
332            | Action::SelectOvertype(_)
333            | Action::ExitSelect
334            | Action::ToggleVisualSelect
335            | Action::ReselectLastVisual
336            | Action::SwapVisualEnds
337            | Action::SetMark(_)
338            // 5.5.G.3: pure-editor edit-cluster arms migrated to
339            // `Editor::dispatch`.
340            | Action::Undo
341            | Action::Redo
342            | Action::JoinLines { .. }
343            | Action::ToggleCaseAtCursor
344            | Action::EnterAppend
345            | Action::OpenLineBelow
346            | Action::OpenLineAbove
347            | Action::OverwriteChar(_)
348            | Action::ReplaceUndoLast
349            | Action::DeleteCharBackward
350            | Action::InsertLineEdit(_)
351            // 5.5.G.4: pure-editor scroll / viewport / page / bracket
352            // / redraw arms migrated to `Editor::dispatch`.
353            | Action::JumpViewport(_)
354            | Action::ScrollCursorTo(_)
355            | Action::HorizontalScroll(_)
356            | Action::PageDown
357            | Action::HalfPageDown
358            | Action::HalfPageUp
359            | Action::PageUp
360            | Action::ScrollLineUp
361            | Action::ScrollLineDown
362            | Action::MatchBracket
363            | Action::RedrawScreen
364            // 5.5.G.5: pure-editor pane-navigation arms migrated.
365            | Action::SplitPaneHorizontal
366            | Action::SplitPaneVertical
367            | Action::ClosePane
368            | Action::OnlyPane
369            // ZP.2: zoom is host-resident tree state read back
370            // through the published `PaneTree`; nothing app-side.
371            | Action::ToggleZoomPane
372            | Action::NavigatePane(_)
373            | Action::NextPane
374            | Action::PrevPane
375            // Issue #28 (2026-05-22): pane-resize / equalize.
376            // Host-resident bodies; grouped no-op here.
377            | Action::EqualizePanes
378            | Action::GrowPaneHeight
379            | Action::ShrinkPaneHeight
380            | Action::GrowPaneWidth
381            | Action::ShrinkPaneWidth
382            // Issue #29 (2026-05-22): tabs. Host-resident.
383            | Action::NextTab
384            | Action::PrevTab
385            | Action::GoToTab(_)
386            | Action::NewTab
387            | Action::NewTabAt(_)
388            | Action::CloseTab
389            // Issue #40 / Terminal-mode T1: host-resident.
390            | Action::TerminalSpawn(_)
391            // Terminal-mode T2.a: host-resident encoder + mode-toggle handlers.
392            | Action::TerminalInput(_)
393            | Action::EnterTerminalInsert
394            | Action::ExitTerminalInsert
395            | Action::TerminalScroll(_)
396            | Action::TerminalArmExitChord
397            | Action::OnlyTab
398            | Action::MoveTab(_)
399            | Action::MovePaneToNewTab
400            // Issue #32 (2026-05-22): picker open-target overrides.
401            // Host-resident bodies; grouped no-op here.
402            | Action::PickerAcceptInSplit
403            | Action::PickerAcceptInVSplit
404            | Action::PickerAcceptInTab
405            // 5.5.G.6: pure-editor mark-history arms migrated.
406            | Action::WalkMarkHistoryBack
407            | Action::WalkMarkHistoryForward
408            // 5.5.G.7: tag-stack / mark-jump / jump-history arms.
409            | Action::TagStackPop
410            | Action::JumpToMarkLine(_)
411            | Action::JumpToMarkExact(_)
412            | Action::JumpHistoryBack
413            | Action::JumpHistoryForward
414            // PBH.3: the per-pane buffer trail (`<C-6>` / `<C-7>`).
415            // Pure-editor like its position-ring siblings above.
416            | Action::PaneHistoryBack
417            | Action::PaneHistoryForward
418            // SN.2b (2026-06-12): `<Tab>` / `<S-Tab>` placeholder
419            // navigation is mode-owned (`active-snippet-mode`'s
420            // `ActionHandlerRegistry` closures); the
421            // `Action::SnippetNext/PrevPlaceholder` variants are gone.
422            // CR.1 (2026-06-24): `Action::DiffGet`/`DiffPut` deleted; the
423            // diff `do`/`dp` chords flow through `Action::ApplyEdit` below
424            // (mode-owned `DiffMode::action_handlers()` → `Effect::ApplyEdit`).
425            // CR.0: generic edit-apply primitive. Host-resident body in
426            // `Editor::handle_action` (`apply_edit_effect_inline`); grouped
427            // no-op here.
428            | Action::ApplyEdit { .. }
429            // CG.1: `<C-g>` foreground cancel. Host-resident body
430            // (`Editor::cancel_foreground`) so the TUI and GPUI peers get
431            // identical behaviour without either renderer owning a copy;
432            // grouped no-op here.
433            | Action::Cancel
434            // 5.5.G.9: paste cluster arms.
435            | Action::PasteAfter
436            | Action::PasteBefore
437            | Action::PasteText(_)
438            // 5.5.G.10: search-state arms migrated to Editor::dispatch.
439            | Action::SearchAppend(_)
440            | Action::SearchBackspace
441            | Action::SearchSubmit
442            | Action::SearchCancel
443            | Action::SearchNext
444            | Action::SearchPrevious
445            | Action::SearchWordUnderCursor(_)
446            | Action::SearchLineSubmit
447            | Action::SearchLineCancel
448            | Action::SearchLineBackspace
449            | Action::SearchLineHistoryPrev
450            | Action::SearchLineHistoryNext
451            | Action::SearchLineToggleExpand
452            | Action::PromptLineSubmit
453            | Action::PromptLineCancel
454            // 5.5.G.11: picker append/backspace/select + close-hover.
455            | Action::PickerAppend(_)
456            | Action::PickerBackspace
457            | Action::PickerSelectNext
458            | Action::PickerSelectPrev
459            // PC.10: `<C-l>` / `<C-w>` — host-side, like every other picker
460            // navigation arm above. The source answers; the renderer has
461            // nothing to add.
462            | Action::PickerDescend
463            | Action::PickerAscendOrDeleteWord
464            // PH.1: `<C-h>` — the host picks the page and returns the same
465            // `DisplayBuffer` signal `:help` does.
466            | Action::PickerHelp
467            // PP.5: `<Tab>` — drills in where the source has depth, selects
468            // next everywhere else. Host-side like its two neighbours; an
469            // action missing from THIS list is a key that silently does
470            // nothing, which is the shape of the report that produced it.
471            | Action::PickerDescendOrSelectNext
472            // PD.1: `<C-d>` — the source names the verb, the host runs it and
473            // re-lists. Host-side like its neighbours.
474            | Action::PickerDelete
475            | Action::CloseHover
476            // 5.5.G.12: HelpDismiss migrated to Editor::dispatch.
477            | Action::HelpDismiss
478            // 5.5.G.13: pure-editor cmdline arms.
479            | Action::OpenCommandPicker
480            // MB.3: `q:` history picker — routed to the host dispatcher.
481            | Action::OpenHistoryPicker
482            // MB.5: `q/` / `q?` search-history picker — routed to the host dispatcher.
483            | Action::OpenSearchHistoryPicker
484            | Action::EnterCommandLine
485            | Action::CommandLineHistoryPrev
486            | Action::CommandLineHistoryNext
487            // 5.5.G.14: completion-cancel cluster + docs scroll +
488            // foldenable toggle.
489            | Action::CompletionCancel
490            | Action::CompletionCancelAndExitInsert
491            | Action::CompletionDocsScrollDown
492            | Action::CompletionDocsScrollUp
493            | Action::ToggleFoldEnable
494            // 5.5.G.15: cmdline-completion popup nav.
495            | Action::CommandLineCompletePrev
496            | Action::CommandLineAcceptCompletion
497            // 5.5.G.16: `zf` Visual-selection fold creation.
498            | Action::CreateFoldFromVisual
499            // 5.5.G.17: modal-state pivot + blockwise-Visual I/A.
500            | Action::EnterMode(_)
501            | Action::EnterBlockVisualInsert
502            | Action::EnterBlockVisualAppend
503            // L7: the 6 nav `Action::Lsp*Request` no-op arms (`K` / `gd`
504            // / `gD` / `gy` / `gI` / `gr`) removed with the variants —
505            // the nav surface is mode-owned (`Effect::Lsp`), host-applied.
506            // 5.5.LSP.4: signature help + completion request migrated
507            // to `Editor::dispatch`.
508            | Action::LspSignatureHelpRequest
509            | Action::LspCompletionRequest
510            // 5.5.LSP.5: document symbol request migrated.
511            // (`LspWorkspaceSymbolRequest` carries a `String`
512            // payload, so it lives in its own ignore-binding arm
513            // below.)
514            | Action::LspDocumentSymbolRequest
515            // SN.3c.1 (2026-06-14): `Action::SnippetExpand` removed —
516            // `<C-x><C-s>` is mode-owned (`Effect::ExpandSnippet`).
517            // Phase 5.8.AF.5 / Slice 3c.final.C: renderer-side
518            // non-dispatch mutation lifts. Bodies live in
519            // `Editor::dispatch`'s match; the App wrapper just
520            // includes them in the grouped no-op band so the
521            // exhaustiveness check passes without splitting App's
522            // logic. Same pattern the other 5.5.G migrations use.
523            | Action::SetViewportHeight(_)
524            | Action::EnsureCursorVisible
525            | Action::DismissPopup
526            | Action::SetTerminalWidth(_)
527            // IM.7a: the TUI never DISPATCHES this one — it draws alt text,
528            // not pixels, so it has no cell geometry worth publishing and
529            // the host reserves the provisional rows without reading a
530            // single image header. Listed here so the exhaustiveness check
531            // records the decision rather than the absence.
532            | Action::SetCellMetrics { .. }
533            | Action::AcknowledgeRedraw
534            // W.6 (2026-06-07): display-line motions (gj/gk/g0/g$)
535            // and insert-mode entry variants (I/A) — host-resident
536            // bodies in Editor::dispatch; no App-side work.
537            | Action::EnterInsertFirstNonBlank
538            | Action::EnterAppendEndOfLine
539            | Action::DisplayLineDown
540            | Action::DisplayLineUp
541            | Action::DisplayLineStart
542            | Action::DisplayLineEnd
543            // PICK.1: transient-mode actions are host-handled
544            // (routed through Editor::dispatch in the host match,
545            // grouped no-op here for exhaustiveness).
546            | Action::TransientTrigger(_)
547            | Action::TransientToggleFlag(_)
548            | Action::TransientDismiss => {}
549            // 5.5.LSP.5: workspace-symbol request migrated to
550            // `Editor::dispatch`. Action carries the query string;
551            // a data-bearing variant can't sit in the grouped
552            // `_ => {}` band (binding mismatch).
553            Action::LspWorkspaceSymbolRequest(_) => {}
554            // 5.5.G.23: keystone — `Invoke` is host-handled now via
555            // `editor.run_invocation` (the host pushes any
556            // renderer-coupled effects into `outcome.effects`, which
557            // the dispatch wrapper above drained before this match
558            // ran). The `_` binding can't sit in the grouped no-op
559            // band because of the inner `CommandInvocation` payload.
560            Action::Invoke(_) => {}
561            // 5.5.G.23.insert: Insert(s) is host-handled via
562            // `editor.do_insert_text`; the host queues LSP autopilot
563            // follow-ups (`LspOnTypeFormattingRequest`,
564            // `LspInsertCompletionRequest`) through `out.next_actions`
565            // which the dispatch wrapper already drained.
566            Action::Insert(_) => {}
567            // 5.5.G.23.insert: LSP autopilot follow-ups host-emitted
568            // by `Editor::do_insert_text` (forthcoming). The handlers
569            // live App-side because they reach for `spawn_on_lsp_runtime`
570            // + `BatchingSink` + the `pending_insert_completion_lsp_*`
571            // channels.
572            // Phase 5.8.AF: migrated to host (consumed = true).
573            Action::LspOnTypeFormattingRequest(_) => {}
574            Action::LspInsertCompletionRequest => {}
575            // 5.5.G.3: `DeleteCharBackward`, `EnterAppend`,
576            // `OpenLineBelow`, `OpenLineAbove`, `Undo`, `Redo`
577            // migrated to `Editor::dispatch`; routed through the
578            // grouped no-op above.
579            // 5.5.G.17: `EnterMode` / `EnterBlockVisualInsert` /
580            // `EnterBlockVisualAppend` migrated to `Editor::dispatch`.
581
582
583            // 5.5.G.13: `EnterCommandLine` migrated to `Editor::dispatch`.
584            // 5.5.G.23.cmdline: Append / Backspace host-handled via
585            // `editor.do_command_line_{append,backspace}`.
586            Action::CommandLineAppend(_) | Action::CommandLineBackspace => {}
587            // 5.5.G.23.cmdline: Submit host-handled via
588            // `editor.do_command_line_submit` (missing-arg prompt +
589            // execute_ex_line + history push + state reset).
590            Action::CommandLineSubmit => {}
591            // 5.5.G.13: `CommandLineHistoryPrev` / `CommandLineHistoryNext`
592            // migrated to `Editor::dispatch`.
593
594            // 5.5.G.11: `CloseHover` / `PickerAppend` /
595            // `PickerBackspace` / `PickerSelectNext` /
596            // `PickerSelectPrev` migrated to `Editor::dispatch`.
597            // Phase 5.8.AF: migrated to host (consumed = true).
598            // LR.5: `PickerBulkAccept` is host-applied like its peers.
599            Action::PickerAccept | Action::PickerBulkAccept | Action::PickerDismiss => {}
600
601            // 5.5.G.2: `EnterVisual` / `ExitVisual` / `ReselectLastVisual`
602            // migrated to `Editor::dispatch`; routed through the
603            // grouped no-op above.
604            // 5.5.G.10: `SearchWordUnderCursor` migrated.
605            // 5.5.G.4: `MatchBracket` migrated to `Editor::dispatch`.
606            // 5.5.G.3: `ToggleCaseAtCursor` / `JoinLines` migrated.
607            // 5.5.G.23.macros: `;` / `,` host-handled via
608            // `editor.do_find_repeat`; the synthesized invocation
609            // routes through `run_invocation` and its effects drain
610            // via `outcome.effects`.
611            Action::FindRepeat { .. } => {}
612
613            // 5.5.G.16: `CreateFoldFromVisual` migrated to `Editor::dispatch`.
614            // 5.5.G.1: `OpenFoldAtCursor` / `CloseFoldAtCursor` /
615            // `ToggleFoldAtCursor` / `OpenAllFolds` / `CloseAllFolds`
616            // / `DeleteFoldAtCursor` / `GotoNextFold` / `GotoPrevFold`
617            // migrated to `Editor::dispatch`; routed through the
618            // grouped no-op above.
619            // 5.5.G.14: `ToggleFoldEnable` migrated to `Editor::dispatch`.
620            // L7: the 7 nav `Action::Lsp*Request` variants (`K` / `gd` /
621            // `gD` / `gy` / `gI` / `gr` / `gx`) are gone — the nav surface
622            // is mode-owned (`Effect::Lsp`), host-applied, so there is no
623            // App-side arm to no-op.
624            // 5.5.LSP.4: `LspSignatureHelpRequest` /
625            // `LspCompletionRequest` migrated to `Editor::dispatch`;
626            // fall through to the grouped no-op below.
627            // 5.5.G.7: `TagStackPop` migrated to `Editor::dispatch`.
628            // Phase 5.8.AF: migrated to host (consumed = true).
629            Action::CompletionTrigger
630            | Action::CompletionNext
631            | Action::CompletionPrev
632            | Action::CompletionAccept
633            | Action::CompletionToggleDocs
634            | Action::CompletionFilterToSource(_)
635            | Action::CompletionFilterClear
636            | Action::CompletionAcceptThenInsert(_)
637            // YR.5: both are handled host-side in `handle_action` — the
638            // TUI has nothing renderer-coupled to add.
639            | Action::InsertRegister(_)
640            | Action::OpenYankPicker
641            // YR.6: same — `do_open_arg_picker` is entirely host-side.
642            | Action::OpenArgPicker => {}
643            // SN.3c.1 (2026-06-14): `Action::SnippetExpand` removed;
644            // `<C-x><C-s>` is mode-owned (`Effect::ExpandSnippet`).
645            // 5.5.G.8: `SnippetNextPlaceholder` / `SnippetPrevPlaceholder`
646            // migrated to `Editor::dispatch`.
647            // 5.5.G.1: `SnippetLeave` migrated to `Editor::dispatch`;
648            // routed through the grouped no-op above.
649            // 5.5.LSP.5: `LspDocumentSymbolRequest` /
650            // `LspWorkspaceSymbolRequest(query)` migrated to
651            // `Editor::dispatch`; fall through to the grouped no-op
652            // below. (LspWorkspaceSymbolRequest carries data, so
653            // it lives in its own grouped arm.)
654            // 5.5.G.7: `JumpHistoryBack` / `JumpHistoryForward`
655            // migrated to `Editor::dispatch`.
656            // 5.5.G.4: `RedrawScreen` migrated to `Editor::dispatch`.
657            // 5.5.G.6: `WalkMarkHistoryBack` / `WalkMarkHistoryForward`
658            // migrated to `Editor::dispatch`.
659
660            // 5.5.G.23.macros: `PlayMacro` / `PlayLastMacro` are
661            // host-handled via `editor.do_play_macro` /
662            // `do_play_last_macro`; recorded actions stream through
663            // `out.next_actions` and the dispatch wrapper's drain
664            // (with `should_quit` short-circuit).
665            Action::PlayMacro(_) | Action::PlayLastMacro => {}
666
667            // 5.5.G.3: `OverwriteChar` / `ReplaceUndoLast` migrated
668            // to `Editor::dispatch`.
669            // 5.5.G.4: `JumpViewport` / `ScrollCursorTo` / `PageDown`
670            // / `PageUp` / `ScrollLineUp` / `ScrollLineDown` migrated.
671
672            // 5.5.G.2: `SetMark` migrated to `Editor::dispatch`.
673            // 5.5.G.7: `JumpToMarkLine` / `JumpToMarkExact` migrated.
674
675            // 5.5.G.23.macros: `.` host-handled via
676            // `editor.do_repeat_last_change` (re-dispatches the
677            // captured invocation through host's `run_invocation` and
678            // replays the captured Insert tail through host's
679            // `do_insert_text`). Effects + signals + LSP follow-ups
680            // drain through the outcome channels above.
681            Action::RepeatLastChange => {}
682
683            // 5.5.G.9: `PasteAfter` / `PasteBefore` / `PasteText`
684            // migrated to `Editor::dispatch`.
685
686            // 5.5.G.23.cmdline: Clear / DeleteWordBackward /
687            // DescribeUnderCursor / AppendChord / CompleteOrAdvance
688            // all host-handled. AppendChord's auto-submit-on-chord
689            // semantic threads through host's
690            // `do_command_line_append_chord` -> `do_command_line_submit`.
691            Action::CommandLineClear
692            | Action::CommandLineDeleteWordBackward
693            | Action::CommandLineDescribeUnderCursor
694            | Action::CommandLineCompleteOrAdvance => {}
695            Action::CommandLineAppendChord(_) => {}
696            // 5.5.G.15: `CommandLineCompletePrev` /
697            // `CommandLineAcceptCompletion` migrated to `Editor::dispatch`.
698
699            // 5.5.G.12: `HelpDismiss` migrated to `Editor::dispatch`.
700            // Phase 5.8.AF: FollowLink Oil/FileTree arms migrated
701            // Phase 5.8.AF: FollowLink fully migrated host-side as
702            // of 2026-05-27. Help-link follow runs through
703            // `Editor::do_help_follow_link`; Oil / FileTree arms
704            // already lived there. This arm is now a host-handled
705            // no-op so the post-dispatch hooks (ensure_cursor_visible,
706            // hover auto-dismiss) still run.
707            Action::FollowLink => {}
708            // MO.2: both mouse gestures are host-handled
709            // (`Editor::do_mouse_scroll` / `do_mouse_goto`). No-ops here
710            // so the post-dispatch hooks still run — a click that moved
711            // the cursor wants `ensure_cursor_visible` and hover
712            // auto-dismiss exactly as a `j` does.
713            Action::MouseScroll { .. } | Action::MouseGoto { .. } => {}
714            // Phase 5.8.AF: migrated to host (consumed = true).
715            Action::OilNavigateUp => {}
716            // M.10.7 (2026-06-03): four Action arms removed —
717            // `MultibufferExpand`, `SearchTrigger`,
718            // `SearchJumpToSource`, `SearchRefresh`. The variants
719            // themselves are gone from the enum; the four
720            // chord / ex-command routes are mode-owned via the
721            // M.10.1.b ActionHandlerRegistry (search mode's
722            // `<CR>` + `gr`) or work inline in the
723            // `AppEffect::SearchTrigger` /
724            // `AppEffect::MultibufferExpand` apply_effect arms.
725
726            // 5.5.G.5: `SplitPaneHorizontal` / `SplitPaneVertical` /
727            // `ClosePane` / `NavigatePane` / `NextPane` / `PrevPane`
728            // migrated to `Editor::dispatch`.
729
730            // 5.5.G.10: SearchAppend / Backspace / Submit / Cancel /
731            // Next / Previous migrated to `Editor::dispatch`.
732        }
733        // Skip ensure_cursor_visible when the popup just dismissed
734        // back to the document. `app.editor.viewport_height` is still the
735        // popup's small inner height at this point (runtime resets
736        // it on the *next* iteration), so running ensure on the
737        // restored doc cursor / scroll would adjust scroll against
738        // the wrong viewport and visibly jolt the backdrop on
739        // close. The next iteration's render fires with the correct
740        // viewport and the restored (cursor, scroll) is already a
741        // valid view -- it's the pair we captured pre-popup.
742        let popup_dismissed = matches!(pre_active, BufferKind::Help)
743            && matches!(self.ad().buffer_kind, BufferKind::Document);
744        if !popup_dismissed {
745            self.ensure_cursor_visible();
746        }
747        self.maybe_reparse_syntax();
748        // The State-A hover auto-dismiss lived HERE as well as in
749        // `Editor::dispatch`, and the duplicate is deleted rather than
750        // fixed. Two copies of one state machine is the defect: the host's
751        // comment says this behaviour was hoisted "so the GPUI peer gets the
752        // same behaviour without duplicating the state machine", and this
753        // copy stayed behind — so a fix to the host's ran in every host test
754        // and in neither renderer.
755        //
756        // Both made the same mistake, which is how it was found: they read
757        // `buffer_kind == Document` as "the popup is unfocused", and the
758        // minibuffers ARE Documents (popup-unification.md §9). `/` inside a
759        // focused popup therefore dismissed it on the first character typed.
760        let _ = pre_active;
761        // Slice 8.f: re-stack Insert-mode minor-mode layers in
762        // lockstep with overlay state changes. Cheap when
763        // nothing changed.
764        self.sync_keymap_overlays();
765        // Phase 5.8.AF.5 / Slice X1: drain pending LSP / event /
766        // mode-lifecycle results here at the keystroke-driven
767        // dispatch tail rather than in the per-frame body
768        // (`crates/lattice-ui-tui/src/runtime.rs`'s main_loop).
769        // Paramount goal #1 forbids I/O / event drain on the UI
770        // thread; the renderer body is the UI thread.
771        // `run_tick_pending` is the host aggregator that polls
772        // ~30 channels for async results -- on a busy frame
773        // (file open) it can take 49ms, which is 6x over the
774        // one-frame ceiling (8.3 ms at 120Hz). Running it
775        // here makes that cost happen during the keystroke that
776        // caused the work (the open) -- the one frame where the
777        // ceiling doesn't apply; subsequent frames see only what
778        // the dispatch tail published.
779        //
780        // Recursive `apply` calls (next_actions) drain again --
781        // cheap because the prior call already emptied the
782        // channels. Idle LSP arrivals (response with no
783        // keystroke in flight) are NOT drained until the next
784        // keystroke: see slice X1b (docs/dev/operations/
785        // render-thread-discipline-remediation.md §X1b) for the
786        // wake-bridge that closes that gap.
787        // Slice 3c.final.E.3: route through `mutate_editor_with`.
788        let tick_signals = self.mutate_editor_with(|e| e.run_tick_pending());
789        for signal in tick_signals {
790            self.handle_renderer_signal(signal);
791        }
792        self.drain_queued_renderer_effects();
793        // Reached only on the non-fused path (e.g. a popup was up). `declined`
794        // is false here for any buffer-mutating action; it can be true when a
795        // declining action didn't fuse (popup open), so carry it out.
796        declined
797    }
798
799    /// Drain pending async results (hover, signature-help, etc.) without a
800    /// full keystroke apply. Called from the TUI main loop on `Wake::Repaint`
801    /// batches that contained no input events — the X1b gap: idle LSP
802    /// responses arrive, fire `paint_request`, the loop wakes and redraws,
803    /// but the pending channels are only drained at the apply tail so the
804    /// first repaint doesn't show the popup until the next keystroke.
805    pub fn drain_async_pending(&mut self) {
806        let tick_signals = self.mutate_editor_with(|e| e.run_tick_pending());
807        for signal in tick_signals {
808            self.handle_renderer_signal(signal);
809        }
810        self.drain_queued_renderer_effects();
811    }
812
813    /// OR.16: apply the renderer-owned effects an off-renderer path queued.
814    ///
815    /// The async picker accept, the fill target and the picker's delete verb
816    /// run with no renderer to hand effects to. They apply what they can and
817    /// queue the rest; this is where the TUI picks them up, through the same
818    /// arms a keystroke's effects go through. Draining beside the tick's
819    /// signals is deliberate: every caller of one wants the other, and the
820    /// GPUI peer does the same in `apply` (cross-renderer rule).
821    pub(super) fn drain_queued_renderer_effects(&mut self) {
822        let effects = self.mutate_editor_with(|e| e.drain_pending_renderer_effects());
823        for effect in effects {
824            self.apply_effect_app_arms(effect);
825        }
826    }
827
828    pub(super) fn execute_ex_line(&mut self, line: &str) {
829        let reg = self.registry();
830        match excommand::parse(line, &reg) {
831            Ok(inv) => match self.dispatch_blocking(inv) {
832                Ok(eff) => self.apply_effect(eff),
833                Err(e) => self.set_message(EchoLevel::Error, e.to_string()),
834            },
835            Err(err) => {
836                self.set_message(EchoLevel::Error, err.to_string());
837            }
838        }
839    }
840
841    /// 5.5.G.23: body migrated to
842    /// [`lattice_host::dispatch::Editor::run_invocation`]. Retained as
843    /// a 1-line delegate because the host-side `Action::Invoke` arm
844    /// already routes through `editor.run_invocation` — App-side
845    /// callers that still reach this method (`do_play_macro`,
846    /// `do_find_repeat`, `RepeatLastChange`) collapse on their own
847    /// slice migration.
848    pub(super) fn run_invocation(&mut self, inv: CommandInvocation) {
849        // Slice 3c.final.E.3: build `out` inside the closure and
850        // return it so the renderer can drain its effects/signals.
851        let mut out = self.mutate_editor_with(move |e| {
852            let mut out = lattice_host::dispatch::DispatchOutcome::default();
853            e.dispatch_invocation(inv, &mut out);
854            out
855        });
856        for effect in std::mem::take(&mut out.effects) {
857            self.apply_effect_app_arms(effect);
858        }
859        for signal in std::mem::take(&mut out.renderer_signals) {
860            self.handle_renderer_signal(signal);
861        }
862    }
863
864    /// Dispatch a `CommandInvocation` against the active oil
865    /// buffer's rope. Oil's content lives in `oil.content` (a
866    /// `Buffer`), separate from `self.editor.document` (the actor-
867    /// backed document buffer). The grammar dispatcher only
868    /// knows about `Document`, so we synthesise a temporary
869    /// `Document` from oil's rope, dispatch through it, and
870    /// copy the resulting buffer back. Edits + cursor updates
871    /// land on the oil rope without touching the document
872    /// actor.
873    ///
874    /// This is the seam that makes oil writable. v1 supports
875    /// motions, operators (insert / delete / change / yank),
876    /// and visual selections against oil's rope. The `:w`
877    /// path is separate (`do_write` matches on
878    /// `BufferKind::Oil` and routes to `OilBuffer::apply`).
879    /// 5.5.G.23: body migrated to
880    /// [`lattice_host::dispatch::Editor::run_oil_invocation`].
881    /// Retained as a 1-line delegate while the App-side outer
882    /// `run_invocation` router still routes here; collapses with
883    /// the router on the keystone wire-up.
884    fn run_oil_invocation(&mut self, inv: CommandInvocation) {
885        // Slice 3c.final.E.3: route through `mutate_editor`.
886        // 2026-05-26: host returns `bool` (handled flag) for the
887        // per-kind-first dispatch reorder; discard it here since
888        // App-side direct callers don't fall back to a grammar
889        // gate — they only invoke the runner when the buffer kind
890        // is already established.
891        self.mutate_editor(move |e| {
892            let _ = e.run_oil_invocation(inv);
893        });
894    }
895
896    /// 5.5.G.23: body migrated to
897    /// [`lattice_host::dispatch::Editor::run_file_tree_invocation`].
898    fn run_file_tree_invocation(&mut self, inv: CommandInvocation) {
899        self.mutate_editor(move |e| {
900            let _ = e.run_file_tree_invocation(inv);
901        });
902    }
903
904    /// 5.5.G.23: body migrated to
905    /// [`lattice_host::dispatch::Editor::run_help_invocation`].
906    fn run_help_invocation(&mut self, inv: CommandInvocation) {
907        self.mutate_editor(move |e| {
908            let _ = e.run_help_invocation(inv);
909        });
910    }
911
912    /// 5.5.G.23: body migrated to
913    /// [`lattice_host::dispatch::Editor::run_read_only_motion`].
914    fn run_read_only_motion(&mut self, inv: CommandInvocation) {
915        self.mutate_editor(move |e| {
916            let _ = e.run_read_only_motion(inv);
917        });
918    }
919
920    /// 5.5.G.23: body migrated to
921    /// [`lattice_host::dispatch::Editor::run_document_invocation`].
922    /// Retained as a 1-line delegate; App-side internal callers
923    /// (`do_play_macro`, `do_find_repeat`, etc.) collapse to host
924    /// recursion on their own slice migration.
925    fn run_document_invocation(&mut self, inv: CommandInvocation) {
926        // Slice 3c.final.E.3: build `out` inside the closure.
927        let mut out = self.mutate_editor_with(move |e| {
928            let mut out = lattice_host::dispatch::DispatchOutcome::default();
929            e.run_document_invocation(inv, &mut out);
930            out
931        });
932        for effect in std::mem::take(&mut out.effects) {
933            self.apply_effect_app_arms(effect);
934        }
935        for signal in std::mem::take(&mut out.renderer_signals) {
936            self.handle_renderer_signal(signal);
937        }
938    }
939
940    pub(super) fn apply_effect(&mut self, effect: Effect) {
941        // Phase 5.5.E.1: route every Effect through `Editor::handle_effect`
942        // first so the host owns its migrated arms (today:
943        // `Effect::None`, `Effect::ClearSearchHighlight`, `Effect::Echo`,
944        // `Effect::EchoMarks`, `Effect::EchoRegisters`, `Effect::Yank`,
945        // `Effect::SelectionChange`).
946        //
947        // Phase 5.5.E.5: the returned `DispatchOutcome` is now consumed --
948        // any `RendererSignal`s the host queued (e.g. `ThemeChanged` from
949        // a future `Effect::SetOption` migration) drain through
950        // [`Self::handle_renderer_signal`] AFTER the post-effect match
951        // below runs. Today no migrated arm pushes a signal, so the loop
952        // is a no-op; the pipe is wired so subsequent E.* slices that
953        // emit signals don't need to rewire the call site.
954        //
955        // 5.5.G.23: body extracted into [`Self::apply_effect_app_arms`]
956        // so the host-side `Editor::run_invocation` (forthcoming) can
957        // push effects into `outcome.effects` and the renderer's
958        // dispatch wrapper drains via the renderer-coupled match arm
959        // only — never re-running `handle_effect` and double-executing
960        // host-migrated arms.
961        // Slice 3c.final.E.3: route through `mutate_editor_with`.
962        let effect_for_host = effect.clone();
963        let outcome = self.mutate_editor_with(move |e| e.handle_effect(effect_for_host));
964        self.apply_effect_app_arms(effect);
965        for signal in outcome.renderer_signals {
966            self.handle_renderer_signal(signal);
967        }
968    }
969
970    /// 5.5.G.23: the renderer-coupled effect match — every arm that
971    /// still drives an App-side `do_*` helper. Drained by
972    /// [`Self::apply_effect`] for App-originated effects, and by the
973    /// dispatch wrapper for effects the host queued into
974    /// `outcome.effects` (those already had `editor.handle_effect`
975    /// called on them host-side; this method must NOT call
976    /// `handle_effect` again).
977    pub(super) fn apply_effect_app_arms(&mut self, effect: Effect) {
978        match effect {
979            // Phase 5.5.E.1–E.4: migrated arms. Bodies live in
980            // `lattice_host::dispatch::handle_effect`.
981            // AP.0.2: a declined grammar effect is consumed by the dispatcher
982            // (fall-through) and never reaches a renderer as a visible effect;
983            // classify it as a no-op like `Effect::None` for exhaustiveness.
984            Effect::Declined
985            | Effect::None
986            | Effect::ClearSearchHighlight
987            // I3/BC.8c follow-up: SaveBuffer host-applied (reuses do_write,
988            // joins BufferDelete); peer no-ops it.
989            | Effect::SaveBuffer { .. }
990            | Effect::Echo { .. }
991            | Effect::ShowDiagnosticsPopup { .. }
992            // L7: LSP nav requests are host-applied (`editor.lsp_request`).
993            | Effect::Lsp(_)
994            | Effect::EchoMarks
995            | Effect::EchoRegisters
996            | Effect::Yank { .. }
997            | Effect::CursorMove(_)
998            // MG.18d: host-applied (and dropped when its target is not
999            // the focused buffer); peer no-ops it like `CursorMove`.
1000            | Effect::CursorMoveIn { .. }
1001            | Effect::SelectionChange(_)
1002            | Effect::EnterMode(_)
1003            | Effect::SetOption { .. }
1004            | Effect::SetLocalOption { .. }
1005            | Effect::SetGlobalOption { .. }
1006            // T.9.b: `:colorscheme` swaps the registry host-side in
1007            // `Editor::handle_effect` + emits `ThemeChanged`; the TUI
1008            // rebuilds off that signal, so nothing to apply here
1009            // (parity with `SetOption`'s `ui.*` path).
1010            | Effect::SetColorscheme(_)
1011            | Effect::ListBuffers
1012            | Effect::DescribeBuffer
1013            | Effect::DescribeCommand { .. }
1014            | Effect::Apropos { .. }
1015            | Effect::DescribeKey { .. }
1016            | Effect::ListKeymap
1017            | Effect::DescribeOption { .. }
1018            | Effect::DescribeElement { .. }
1019            | Effect::ListOptions
1020            | Effect::DescribePluginApi { .. }
1021            | Effect::ListPluginApis
1022            | Effect::ListCommands
1023            | Effect::DescribePlugin { .. }
1024            | Effect::ListPlugins
1025            | Effect::DescribeOptionResolution { .. }
1026            | Effect::DescribeEvents
1027            | Effect::DescribeEvent { .. }
1028            | Effect::DescribeDiff
1029            | Effect::DiffOpen
1030            | Effect::DiffOff { .. }
1031            | Effect::Diffthis
1032            | Effect::Diffsplit { .. }
1033            | Effect::DiffGetCmd { .. }
1034            | Effect::DiffPutCmd { .. }
1035            | Effect::DiffAccept
1036            | Effect::DiffReject
1037            | Effect::DiffAcceptAll
1038            | Effect::DiffRejectAll
1039            | Effect::CloseSessionDiffs { .. }
1040            | Effect::CloseAllSessionDiffs { .. }
1041            | Effect::NextHunk
1042            | Effect::PrevHunk
1043            | Effect::BufferNext
1044            | Effect::BufferPrev
1045            | Effect::FocusBuffer(_)
1046            | Effect::InvokeCommand { .. }
1047            | Effect::BufferDelete { .. }
1048            | Effect::ListModes
1049            | Effect::DescribeMode { .. }
1050            | Effect::DescribeActiveModes
1051            | Effect::DescribeActiveBindings
1052            | Effect::Customize { .. }
1053            | Effect::ListDiagnostics
1054            | Effect::ListErrors
1055            | Effect::DeleteCurrentLine
1056            | Effect::Substitute { .. }
1057            | Effect::RecordJump
1058            // BC.8c: the showDocument open effects are host-applied in
1059            // `Editor::handle_effect` (they must run host-side because the
1060            // bus drains off-keystroke, where peer-applied effects are not
1061            // forwarded); the peer no-ops them here.
1062            | Effect::OpenExternalUri { .. }
1063            | Effect::OpenBufferAtColumn { .. }
1064            // I5.1: terminal spawn is host-applied; the peer no-ops it.
1065            | Effect::SpawnTerminal { .. }
1066            // D-fix.4: terminal input (`:claude-interrupt`) is host-applied.
1067            | Effect::TerminalInput(_)
1068            // CR.0: host's `handle_effect` translates `ApplyEdit` into
1069            // `Action::ApplyEdit` on `out.next_actions` (drained by the
1070            // dispatch wrapper); nothing renderer-coupled here.
1071            | Effect::ApplyEdit { .. }
1072            // XF.1: host-applied. The host resolves the path to a buffer,
1073            // inserts, and cuts — nothing renderer-coupled, exactly like its
1074            // `ApplyEdit` sibling above.
1075            | Effect::WriteToFile { .. }
1076            | Effect::Edits(_)
1077            // Host-applied cd/pwd effects.
1078            | Effect::ChangeDir(_)
1079            | Effect::PrintWorkingDir
1080            | Effect::PrintProjectRoot => {}
1081            // 5.5.E.7.7: `Edits` migrated to `Editor::handle_effect`;
1082            // routed through the grouped no-op above. `handle_edits`
1083            // and `publish_document_changed` wrappers retired in the
1084            // same slice.
1085            // 5.5.G.23.macros: `Effect::EnterMode` migrated to
1086            // `Editor::handle_effect` so host runners
1087            // (`run_document_invocation`, `do_repeat_last_change`)
1088            // observe the flipped modal state synchronously. App-side
1089            // arm collapses to the grouped no-op below.
1090            // --- Ex-command effects (DESIGN.md §5.2.1 unified dispatch). ---
1091            // These come from ex-command apply closures registered in the
1092            // grammar registry; the host owns the side effects.
1093            // I3/BC.8c follow-up: `SaveBuffer` is now HOST-applied in
1094            // `Editor::handle_effect` (so it works on the off-keystroke inbound
1095            // tick path); the peer arm is retired → grouped no-op below.
1096            Effect::QuitEditor { force, scope } => self.do_quit(force, scope),
1097            Effect::OpenBuffer { path, force } => self.do_edit(path, force),
1098            // M.10.3 bug fix (2026-06-03): atomic open-and-position.
1099            // `do_edit` first (active doc switches to the file),
1100            // THEN set selections (cursor lands at the matched
1101            // row/byte in the new active doc on the next render).
1102            // Splitting the two through `OpenBuffer` +
1103            // `SelectionChange` failed because the host-side
1104            // SelectionChange arm runs synchronously against the
1105            // still-active multibuffer; this variant performs
1106            // both writes against the post-do_edit active doc.
1107            // CD.2: one host body (`Editor::open_buffer_at`) seeds a new
1108            // file, activates the minor and lands — and reveals — the cursor.
1109            // The GPUI peer and the off-renderer drain call the same method.
1110            Effect::OpenBufferAt {
1111                path,
1112                position,
1113                force,
1114                content,
1115                activate_minor,
1116            } => {
1117                let outcome = self.mutate_editor_with(move |e| {
1118                    e.open_buffer_at(
1119                        path,
1120                        position,
1121                        force,
1122                        content.as_deref(),
1123                        activate_minor.as_deref(),
1124                    )
1125                });
1126                self.handle_do_edit_outcome(outcome);
1127            }
1128            // LM.0: open in a target pane. Same host body as OpenBufferAt,
1129            // preceded by the picker-accept split sequence. GPUI peer runs
1130            // the identical pair.
1131            Effect::OpenInTarget {
1132                path,
1133                position,
1134                target,
1135            } => {
1136                let outcome = self.mutate_editor_with(move |e| {
1137                    e.prepare_open_target_pane(target);
1138                    e.open_buffer_at(path, position, false, None, None)
1139                });
1140                self.handle_do_edit_outcome(outcome);
1141            }
1142            // 5.5.E.7.5: `Substitute` migrated to
1143            // `Editor::handle_effect`; routed through the grouped
1144            // no-op above.
1145            Effect::Global {
1146                pattern,
1147                inverted,
1148                body,
1149            } => self.do_global(&pattern, inverted, body.as_ref()),
1150            // 5.5.E.7.4: `DeleteCurrentLine` migrated to
1151            // `Editor::handle_effect`; routed through the grouped
1152            // no-op above.
1153            // 5.5.F.2: `DescribeCommand` / `Apropos` / `DescribeKey`
1154            // / `ListKeymap` migrated to `Editor::handle_effect`;
1155            // the renderer-coupled tail flows back through
1156            // `RendererSignal::DisplayBuffer`. Listed above in the
1157            // grouped no-op.
1158            // 5.5.F.4.3: `BufferNext` / `BufferPrev` migrated to
1159            // `Editor::handle_effect`; emit `BufferActivated` for
1160            // the post-activation tail. Routed through the grouped
1161            // no-op above.
1162            Effect::OpenBufferPicker => self.open_buffer_picker(),
1163            Effect::OpenPicker {
1164                source,
1165                args,
1166                root,
1167                fill_action,
1168                query,
1169            } => self.open_picker_seeded(source, args, root, fill_action, query),
1170            Effect::Confirm {
1171                prompt,
1172                yes_action,
1173                args,
1174            } => self.do_confirm(prompt, yes_action, args),
1175            Effect::OpenTransient { source, args } => self.do_open_transient(source, args),
1176            Effect::OpenPrompt {
1177                prompt,
1178                initial,
1179                on_submit_action,
1180                buffer_name,
1181            } => self.do_open_prompt(prompt, initial, on_submit_action, buffer_name),
1182            // 5.5.F.4.4: `BufferDelete` migrated to
1183            // `Editor::handle_effect`; emits `BufferActivated` for the
1184            // post-activation tail. Routed through the grouped no-op
1185            // below.
1186            Effect::OpenFileTree { root } => self.do_open_file_tree(root),
1187            Effect::CloseFileTree => self.dismiss_file_tree(),
1188            Effect::OpenOil { dir } => self.do_open_oil(dir),
1189            // LM.2: in-place re-list / toggle. Peer-applied like OpenOil;
1190            // the host applier owns the reload + rope rewrite, the mode
1191            // handler (LM.3/LM.4) owns the decision.
1192            Effect::OilNavigate { view, dir, focus } => {
1193                let _ = self.mutate_editor_with(move |e| e.apply_oil_navigate(view, dir, focus));
1194            }
1195            Effect::FileTreeToggle { view, entry_index } => {
1196                let _ =
1197                    self.mutate_editor_with(move |e| e.apply_file_tree_toggle(view, entry_index));
1198            }
1199            // 5.5.F.3: `DescribeOption` / `ListOptions` migrated
1200            // to `Editor::handle_effect`; routed through the
1201            // grouped no-op above.
1202            Effect::OpenHover { markdown } => self.do_open_hover(&markdown),
1203            Effect::DismissPopup => self.do_dismiss_popup(),
1204            // Targeted peer: a no-op unless the popup showing is the named
1205            // one. See `Editor::dismiss_popup_named`.
1206            Effect::DismissPopupNamed { name } => {
1207                self.mutate_editor(move |e| {
1208                    e.dismiss_popup_named(&name);
1209                });
1210            }
1211            Effect::BuryBuffer => {
1212                self.mutate_editor(|e| {
1213                    e.bury_buffer();
1214                });
1215            }
1216            Effect::KillBuffer => {
1217                self.mutate_editor(|e| {
1218                    e.kill_buffer();
1219                });
1220            }
1221            Effect::OpenPopup {
1222                name,
1223                mode_id,
1224                placement,
1225                focus,
1226            } => self.open_popup_named(&name, &mode_id, placement, focus),
1227            Effect::OpenHelpTopic { topic } => self.do_open_help_topic(topic.as_deref()),
1228            // 5.5.F.7: `ListDiagnostics` migrated to
1229            // `Editor::handle_effect`; routed through the grouped
1230            // no-op below.
1231            Effect::NextDiagnostic => self.do_next_diagnostic(),
1232            Effect::PrevDiagnostic => self.do_prev_diagnostic(),
1233            Effect::OpenLspLog { server_id } => self.do_open_lsp_log(server_id.as_deref()),
1234            Effect::OpenAiLog { session } => self.do_open_ai_log(session.as_deref()),
1235            Effect::OpenSyntheticBuffer {
1236                name,
1237                mode_id,
1238                content,
1239                cursor,
1240                activate_minor,
1241            } => {
1242                // OC.7a: the seeded form. `None` throughout is the
1243                // pre-OC.7a call, so native emitters are unaffected.
1244                self.open_synthetic_buffer_seeded(
1245                    &name,
1246                    &mode_id,
1247                    content.as_deref(),
1248                    cursor,
1249                    activate_minor.as_deref(),
1250                )
1251            }
1252            // MG.50: the synthetic peer of `OpenBufferAt`, and the same
1253            // two writes in the same order — open, THEN position against
1254            // the now-active buffer. Splitting them into two effects
1255            // would place the caret against whatever was active before.
1256            Effect::OpenSyntheticBufferAt {
1257                name,
1258                mode_id,
1259                position,
1260            } => {
1261                self.open_synthetic_buffer(&name, &mode_id);
1262                self.mutate_editor_with(move |e| {
1263                    e.set_cursor_clamped(position);
1264                });
1265            }
1266            // PI.2b: dump the plugin-API catalog into a savable buffer.
1267            Effect::ExportPluginApi { format } => {
1268                self.mutate_editor(move |e| e.do_export_plugin_api(format.as_deref()))
1269            }
1270            Effect::OpenMessages => self.do_open_messages(),
1271            // DB.2: host-applied via handle_effect (like DiffOpen); the
1272            // peer has nothing to do.
1273            Effect::OpenDashboard => {}
1274            Effect::ToggleLspTrace { server_id } => self.do_toggle_lsp_trace(&server_id),
1275            Effect::OpenLspTraceLog { server_id } => {
1276                self.do_open_lsp_trace_log(server_id.as_deref())
1277            }
1278            Effect::LspStatus => self.do_lsp_status(),
1279            // EP.4: the manual pull. Host-resident like the feed itself.
1280            Effect::LspDiagnosticsToErrorList => self.do_lsp_diagnostics_to_error_list(),
1281            Effect::LspServerLogListing => self.do_lsp_server_log_listing(),
1282            Effect::LspRestart { server_id } => self.do_lsp_restart(&server_id),
1283            Effect::LspProgressCancel { server_id } => {
1284                self.do_lsp_progress_cancel(server_id.as_deref())
1285            }
1286            Effect::LspExpandRegion => self.do_lsp_expand_region(),
1287            Effect::LspShrinkRegion => self.do_lsp_shrink_region(),
1288            Effect::SetLspLogLevel { server_id, level } => {
1289                self.do_set_lsp_log_level(server_id.as_deref(), &level)
1290            }
1291            Effect::LspLogClear { server_id } => self.do_lsp_log_clear(server_id.as_deref()),
1292            // 5.5.LSP.5: symbol helpers live on `Editor`.
1293            Effect::LspDocumentSymbol => self.mutate_editor_with(move |e| e.lsp_document_symbol_request()),
1294            Effect::LspWorkspaceSymbol { query } => {
1295                self.mutate_editor_with(move |e| e.lsp_workspace_symbol_request(&query))
1296            }
1297            Effect::LspIncomingCalls => self.do_lsp_call_hierarchy_request(false),
1298            Effect::LspOutgoingCalls => self.do_lsp_call_hierarchy_request(true),
1299            Effect::LspSupertypes => self.do_lsp_type_hierarchy_request(false),
1300            Effect::LspSubtypes => self.do_lsp_type_hierarchy_request(true),
1301            Effect::LspMoniker => self.do_lsp_moniker_request(),
1302            Effect::LspCodeLens => self.do_lsp_code_lens_picker(),
1303            Effect::LspColorPresentation => self.do_lsp_color_presentation(),
1304            Effect::Format => self.do_format_request(),
1305            Effect::LspFormat => self.do_lsp_format_request(false),
1306            Effect::LspFormatRange => self.do_lsp_format_request(true),
1307            // 5.5.LSP.4: signature help + completion request now
1308            // live on `Editor`. The Effect routes here continue to
1309            // dispatch via `self.editor.<fn>()` until the Effect
1310            // table itself migrates.
1311            Effect::LspSignatureHelp => self.mutate_editor_with(move |e| e.lsp_signature_help_request()),
1312            Effect::LspComplete => self.mutate_editor_with(move |e| e.lsp_completion_request()),
1313            Effect::LspRename { new_name } => self.do_lsp_rename_request(&new_name),
1314            Effect::LspCodeAction => self.do_lsp_code_action_request(),
1315            // SN.3c.1: mode-owned `<C-x><C-s>` emits the trigger range;
1316            // the host resolves + expands (`expand_snippet_from_range`).
1317            Effect::ExpandSnippet { replace_range } => {
1318                self.mutate_editor_with(move |e| e.expand_snippet_from_range(replace_range))
1319            }
1320            Effect::ReloadSnippets => self.do_reload_snippets(),
1321            Effect::ToggleMode { mode_name } => self.toggle_mode_by_name(&mode_name),
1322            // 5.5.F.3: `DescribeEvents` / `DescribeEvent` /
1323            // `DescribeOptionResolution` migrated to
1324            // `Editor::handle_effect`; routed through the grouped
1325            // no-op above.
1326            // 5.5.F.6: `ListModes` / `DescribeMode` / `Customize`
1327            // migrated to `Editor::handle_effect`; routed through
1328            // the grouped no-op below.
1329            Effect::Tutor { lesson } => self.do_tutor(lesson),
1330            // 5.5.G.24: AppEffect router migrated to
1331            // `Editor::apply_app_effect`. Host either mutates editor
1332            // fields directly (`AbsorbOperatorPrefix`) or pushes a
1333            // follow-up `Action` through `out.next_actions` for the
1334            // renderer's drain. App-side arm is now a no-op; routing
1335            // through this match means a stale apply-effect from
1336            // earlier in the pipeline still reaches the right place.
1337            Effect::AppAction(_) => {}
1338            Effect::Many(many) => {
1339                // 5.5.G.23: inner effects of a `Many` go through the
1340                // full wrapper (`apply_effect`) so each one gets a
1341                // fresh `editor.handle_effect` pass — the host doesn't
1342                // descend into Many in its handler. Same semantics as
1343                // before the split.
1344                for e in many {
1345                    self.apply_effect(e);
1346                }
1347            }
1348        }
1349    }
1350
1351    /// Renderer-side side-effect dispatcher. Receives every
1352    /// [`RendererSignal`] the host emits during a dispatch and runs
1353    /// the TUI-specific follow-up: `ThemeChanged` rebuilds the
1354    /// TUI `Style` cache from `editor.host_theme`; `Quit` is a
1355    /// no-op (already set on `editor.should_quit`, which
1356    /// `runtime::main_loop` reads each tick). A future GPUI renderer
1357    /// implements its own equivalent on `lattice-ui-gpui::App`.
1358    pub fn handle_renderer_signal(&mut self, signal: RendererSignal) {
1359        match signal {
1360            RendererSignal::ThemeChanged => self.rebuild_tui_theme(),
1361            RendererSignal::Quit => {
1362                // `editor.should_quit` was set alongside the signal
1363                // emission (see `Action::Quit` in `Editor::dispatch`);
1364                // the runtime loop polls it. Keep the arm for shape
1365                // — a future GPUI renderer wires window-close here.
1366            }
1367            // 5.5.E.6: `ui.nerd_fonts` cascade -- the renderer walks
1368            // every file-tree buffer and refreshes its rope so the
1369            // icon-glyph cells re-render. Oil reads the toggle each
1370            // frame and needs no rope work. T.6.t: read `nerd_fonts`
1371            // from the published typed-options registry (was the
1372            // deleted `host_theme.nerd_fonts`).
1373            RendererSignal::NerdFontsToggled => {
1374                let nerd_fonts = self
1375                    .render_state
1376                    .load()
1377                    .options
1378                    .config
1379                    .get_typed::<lattice_host::ui::theme_options::UiNerdFonts>()
1380                    .map(|v| *v)
1381                    .unwrap_or(false);
1382                for id in self.buffers().registry.file_tree_ids() {
1383                    self.set_file_tree_nerd_fonts(id, nerd_fonts);
1384                }
1385            }
1386            // 5.5.F.5.4: `MirrorOptionToModes` retired. The cascade
1387            // now runs synchronously host-side inside
1388            // `apply_option_cascade` via `Editor::mirror_option_to_modes`;
1389            // any cascading mode-lifecycle signals stream back through
1390            // the same `Vec<RendererSignal>` the parent cascade already
1391            // drains, so the App-side handler is no longer reachable.
1392            // 5.5.E.6: `lsp.<server>.*` cascade -- fan out
1393            // `workspace/didChangeConfiguration` to every actor
1394            // matching `server_id` with the freshly merged subtree.
1395            RendererSignal::LspConfigChanged(server_id) => {
1396                self.fan_out_did_change_configuration(&server_id);
1397            }
1398            // 5.5.F.1: a host-side `do_*` arm built a HelpContent
1399            // and wants the renderer to surface it under a given
1400            // category. Route through `display_buffer` (the
1401            // renderer's existing dispatch: resolve category to a
1402            // `BufferDisplay` preference, then route popup / pane
1403            // / split). `display_buffer` returns the registered
1404            // BufferId for `ActivePane` / `Split` displays so
1405            // future host slices can mirror it back through a
1406            // dedicated signal; today's callers (ListBuffers,
1407            // DescribeBuffer) don't need the id back so we discard.
1408            RendererSignal::DisplayBuffer(req) => {
1409                let lattice_host::dispatch::DisplayBufferRequest { content, category } = *req;
1410                self.display_buffer(content, category);
1411            } // 5.5.F.5.5: `BufferActivated` retired. The Bucket-A
1412              // `visible_highlights` / `pane_highlights` cache clear
1413              // lives on `Editor` as plain field writes, so the
1414              // post-activation tail (`activate_buffer_state`) runs
1415              // entirely host-side; cascading mode-lifecycle signals
1416              // stream into the `handle_effect` outcome and fan out
1417              // through this same match.
1418        }
1419    }
1420
1421    // 5.5.G.24: App-side `apply_app_effect` retired. Body moved to
1422    // `lattice_host::dispatch::Editor::apply_app_effect`; the host
1423    // mutates editor fields directly for `AbsorbOperatorPrefix` (only
1424    // structural arm) and enqueues a follow-up `Action` via
1425    // `out.next_actions` for every other variant. The renderer's
1426    // existing drain processes those through the full `apply` loop.
1427    // `Effect::AppAction(_)` in `apply_effect_app_arms` collapses to
1428    // the grouped no-op band.
1429    //
1430    // 5.5.E.7.7: `handle_edits` retired -- body moved to
1431    // `lattice_host::dispatch::Editor::handle_edits`, called via the
1432    // `Effect::Edits` arm in `Editor::handle_effect`. The grouped
1433    // no-op above keeps the App-side match exhaustive.
1434}
1435
1436/// True if the Effect indicates an operator-class action (the buffer
1437/// changed or content was yanked). Used by Visual mode to decide whether
1438/// to auto-exit after the dispatch -- motions in Visual should not exit;
1439/// d / y / c should.
1440fn effect_mutates_or_yanks(effect: &Effect) -> bool {
1441    match effect {
1442        Effect::Edits(_) | Effect::Yank { .. } => true,
1443        // CR.0: a pending targeted edit mutates a buffer (like `Edits`).
1444        Effect::ApplyEdit { .. } => true,
1445        // XF.1: mirrors the host classifier — always a mutation (it inserts
1446        // into the target, and with a `cut` deletes from the source too).
1447        Effect::WriteToFile { .. } => true,
1448        // Ex-effects that the host turns into edits / yanks at apply time.
1449        Effect::Substitute { .. } | Effect::Global { .. } | Effect::DeleteCurrentLine => true,
1450        Effect::Many(parts) => parts.iter().any(effect_mutates_or_yanks),
1451        // L4b: the diagnostics popup neither mutates nor yanks.
1452        Effect::ShowDiagnosticsPopup { .. } => false,
1453        // L7: an LSP nav request neither mutates nor yanks.
1454        Effect::Lsp(_) => false,
1455        // Confirm dialog opens a transient — not a mutation/yank.
1456        Effect::Confirm { .. } => false,
1457        // Opening a named transient menu neither mutates nor yanks.
1458        Effect::OpenTransient { .. } => false,
1459        // Opening a text prompt neither mutates nor yanks.
1460        Effect::OpenPrompt { .. } => false,
1461        // AP.0.2: a declined effect did nothing — not a mutation/yank.
1462        Effect::Declined
1463        | Effect::None
1464        | Effect::CursorMove(_)
1465        | Effect::CursorMoveIn { .. }
1466        | Effect::SelectionChange(_)
1467        | Effect::EnterMode(_)
1468        | Effect::SaveBuffer { .. }
1469        | Effect::QuitEditor { .. }
1470        | Effect::OpenBuffer { .. }
1471        | Effect::OpenBufferAt { .. }
1472        // LM.0: opens a buffer in a target pane — same class as OpenBufferAt.
1473        | Effect::OpenInTarget { .. }
1474        // BC.8c: host-applied open effects (showDocument) — non-mutating,
1475        // non-yanking, same as OpenBuffer / OpenBufferAt.
1476        | Effect::OpenExternalUri { .. }
1477        | Effect::OpenBufferAtColumn { .. }
1478        // I5.1: terminal spawn opens a new buffer — non-mutating, non-yanking.
1479        | Effect::SpawnTerminal { .. }
1480        // D-fix.4: terminal input is not a buffer mutation/yank.
1481        | Effect::TerminalInput(_)
1482        | Effect::SetOption { .. }
1483        | Effect::SetLocalOption { .. }
1484        | Effect::SetGlobalOption { .. }
1485        | Effect::SetColorscheme(_)
1486        | Effect::ClearSearchHighlight
1487        | Effect::Echo { .. }
1488        | Effect::EchoRegisters
1489        | Effect::EchoMarks
1490        | Effect::DescribeCommand { .. }
1491        | Effect::DescribeBuffer
1492        | Effect::Apropos { .. }
1493        | Effect::DescribeKey { .. }
1494        | Effect::ListKeymap
1495        | Effect::BufferNext
1496        | Effect::BufferPrev
1497        | Effect::FocusBuffer(_)
1498        | Effect::InvokeCommand { .. }
1499        | Effect::ListBuffers
1500        | Effect::OpenBufferPicker
1501        | Effect::OpenPicker { .. }
1502        | Effect::BufferDelete { .. }
1503        | Effect::OpenFileTree { .. }
1504        | Effect::CloseFileTree
1505        | Effect::OpenOil { .. }
1506        // LM.2: in-place re-list / toggle — navigation, not a user
1507        // edit-mutation or yank.
1508        | Effect::OilNavigate { .. }
1509        | Effect::FileTreeToggle { .. }
1510        | Effect::DescribeOption { .. }
1511        | Effect::DescribeElement { .. }
1512        | Effect::ListOptions
1513        | Effect::DescribePluginApi { .. }
1514        | Effect::ListPluginApis
1515        | Effect::ListCommands
1516        | Effect::DescribePlugin { .. }
1517        | Effect::ListPlugins
1518        | Effect::ExportPluginApi { .. }
1519        | Effect::OpenHover { .. }
1520        | Effect::DismissPopup
1521        | Effect::DismissPopupNamed { .. }
1522        | Effect::BuryBuffer
1523        | Effect::KillBuffer
1524        | Effect::OpenPopup { .. }
1525        | Effect::OpenHelpTopic { .. }
1526        | Effect::ListDiagnostics
1527        | Effect::ListErrors
1528        | Effect::NextDiagnostic
1529        | Effect::PrevDiagnostic
1530        | Effect::OpenLspLog { .. }
1531        | Effect::OpenAiLog { .. }
1532        | Effect::OpenSyntheticBuffer { .. }
1533        | Effect::OpenSyntheticBufferAt { .. }
1534        | Effect::OpenMessages
1535        | Effect::ToggleLspTrace { .. }
1536        | Effect::OpenLspTraceLog { .. }
1537        | Effect::LspStatus
1538        | Effect::LspDiagnosticsToErrorList
1539        | Effect::LspServerLogListing
1540        | Effect::LspRestart { .. }
1541        | Effect::LspProgressCancel { .. }
1542        | Effect::LspExpandRegion
1543        | Effect::LspShrinkRegion
1544        | Effect::SetLspLogLevel { .. }
1545        | Effect::LspLogClear { .. }
1546        | Effect::LspDocumentSymbol
1547        | Effect::LspWorkspaceSymbol { .. }
1548        | Effect::LspIncomingCalls
1549        | Effect::LspOutgoingCalls
1550        | Effect::LspSupertypes
1551        | Effect::LspSubtypes
1552        | Effect::LspMoniker
1553        | Effect::LspCodeLens
1554        | Effect::LspColorPresentation
1555        | Effect::Format
1556        | Effect::LspFormat
1557        | Effect::LspFormatRange
1558        | Effect::LspSignatureHelp
1559        | Effect::LspComplete
1560        | Effect::LspRename { .. }
1561        | Effect::LspCodeAction
1562        | Effect::ExpandSnippet { .. }
1563        | Effect::ReloadSnippets
1564        | Effect::ToggleMode { .. }
1565        | Effect::DescribeEvents
1566        | Effect::DescribeEvent { .. }
1567        | Effect::DescribeDiff
1568        | Effect::DiffOpen
1569        | Effect::DiffOff { .. }
1570        | Effect::Diffthis
1571        | Effect::Diffsplit { .. }
1572        | Effect::DiffGetCmd { .. }
1573        | Effect::DiffPutCmd { .. }
1574        | Effect::DiffAccept
1575        | Effect::DiffReject
1576        | Effect::DiffAcceptAll
1577        | Effect::DiffRejectAll
1578        | Effect::CloseSessionDiffs { .. }
1579        | Effect::CloseAllSessionDiffs { .. }
1580        | Effect::NextHunk
1581        | Effect::PrevHunk
1582        | Effect::ListModes
1583        | Effect::DescribeMode { .. }
1584        | Effect::DescribeActiveModes
1585        | Effect::DescribeActiveBindings
1586        | Effect::DescribeOptionResolution { .. }
1587        | Effect::Customize { .. }
1588        | Effect::Tutor { .. }
1589        | Effect::AppAction(_)
1590        | Effect::RecordJump
1591        | Effect::OpenDashboard
1592        | Effect::ChangeDir(_)
1593        | Effect::PrintWorkingDir
1594        | Effect::PrintProjectRoot => false,
1595    }
1596}
1597
1598/// True if the Effect produced a buffer mutation. Used by dot-repeat
1599/// to decide whether to record the invocation -- yank-only invocations
1600/// (vim's `y`) are NOT eligible for `.`, only changes.
1601fn effect_mutates(effect: &Effect) -> bool {
1602    match effect {
1603        Effect::Edits(_) => true,
1604        // CR.0: a pending targeted edit mutates a buffer (like `Edits`).
1605        Effect::ApplyEdit { .. } => true,
1606        // XF.1: a change rather than a yank, so `.` may repeat it.
1607        Effect::WriteToFile { .. } => true,
1608        Effect::Substitute { .. } | Effect::Global { .. } | Effect::DeleteCurrentLine => true,
1609        Effect::Many(parts) => parts.iter().any(effect_mutates),
1610        // L4b: the diagnostics popup is not a buffer mutation.
1611        Effect::ShowDiagnosticsPopup { .. } => false,
1612        // L7: an LSP nav request is not a buffer mutation.
1613        Effect::Lsp(_) => false,
1614        // Confirm dialog opens a transient — not a buffer mutation.
1615        Effect::Confirm { .. } => false,
1616        // Opening a named transient menu is not a buffer mutation.
1617        Effect::OpenTransient { .. } => false,
1618        // Opening a text prompt is not a buffer mutation.
1619        Effect::OpenPrompt { .. } => false,
1620        // AP.0.2: a declined effect did nothing — not a buffer mutation.
1621        Effect::Declined
1622        | Effect::None
1623        | Effect::CursorMove(_)
1624        | Effect::CursorMoveIn { .. }
1625        | Effect::SelectionChange(_)
1626        | Effect::Yank { .. }
1627        | Effect::EnterMode(_)
1628        | Effect::SaveBuffer { .. }
1629        | Effect::QuitEditor { .. }
1630        | Effect::OpenBuffer { .. }
1631        | Effect::OpenBufferAt { .. }
1632        // LM.0: opens a buffer in a target pane — same class as OpenBufferAt.
1633        | Effect::OpenInTarget { .. }
1634        // BC.8c: host-applied open effects (showDocument) — non-mutating,
1635        // non-yanking, same as OpenBuffer / OpenBufferAt.
1636        | Effect::OpenExternalUri { .. }
1637        | Effect::OpenBufferAtColumn { .. }
1638        // I5.1: terminal spawn opens a new buffer — non-mutating, non-yanking.
1639        | Effect::SpawnTerminal { .. }
1640        // D-fix.4: terminal input is not a buffer mutation/yank.
1641        | Effect::TerminalInput(_)
1642        | Effect::SetOption { .. }
1643        | Effect::SetLocalOption { .. }
1644        | Effect::SetGlobalOption { .. }
1645        | Effect::SetColorscheme(_)
1646        | Effect::ClearSearchHighlight
1647        | Effect::Echo { .. }
1648        | Effect::EchoRegisters
1649        | Effect::EchoMarks
1650        | Effect::DescribeCommand { .. }
1651        | Effect::DescribeBuffer
1652        | Effect::Apropos { .. }
1653        | Effect::DescribeKey { .. }
1654        | Effect::ListKeymap
1655        | Effect::BufferNext
1656        | Effect::BufferPrev
1657        | Effect::FocusBuffer(_)
1658        | Effect::InvokeCommand { .. }
1659        | Effect::ListBuffers
1660        | Effect::OpenBufferPicker
1661        | Effect::OpenPicker { .. }
1662        | Effect::BufferDelete { .. }
1663        | Effect::OpenFileTree { .. }
1664        | Effect::CloseFileTree
1665        | Effect::OpenOil { .. }
1666        // LM.2: in-place re-list / toggle — navigation, not a user
1667        // edit-mutation or yank.
1668        | Effect::OilNavigate { .. }
1669        | Effect::FileTreeToggle { .. }
1670        | Effect::DescribeOption { .. }
1671        | Effect::DescribeElement { .. }
1672        | Effect::ListOptions
1673        | Effect::DescribePluginApi { .. }
1674        | Effect::ListPluginApis
1675        | Effect::ListCommands
1676        | Effect::DescribePlugin { .. }
1677        | Effect::ListPlugins
1678        | Effect::ExportPluginApi { .. }
1679        | Effect::OpenHover { .. }
1680        | Effect::DismissPopup
1681        | Effect::DismissPopupNamed { .. }
1682        | Effect::BuryBuffer
1683        | Effect::KillBuffer
1684        | Effect::OpenPopup { .. }
1685        | Effect::OpenHelpTopic { .. }
1686        | Effect::ListDiagnostics
1687        | Effect::ListErrors
1688        | Effect::NextDiagnostic
1689        | Effect::PrevDiagnostic
1690        | Effect::OpenLspLog { .. }
1691        | Effect::OpenAiLog { .. }
1692        | Effect::OpenSyntheticBuffer { .. }
1693        | Effect::OpenSyntheticBufferAt { .. }
1694        | Effect::OpenMessages
1695        | Effect::ToggleLspTrace { .. }
1696        | Effect::OpenLspTraceLog { .. }
1697        | Effect::LspStatus
1698        | Effect::LspDiagnosticsToErrorList
1699        | Effect::LspServerLogListing
1700        | Effect::LspRestart { .. }
1701        | Effect::LspProgressCancel { .. }
1702        | Effect::LspExpandRegion
1703        | Effect::LspShrinkRegion
1704        | Effect::SetLspLogLevel { .. }
1705        | Effect::LspLogClear { .. }
1706        | Effect::LspDocumentSymbol
1707        | Effect::LspWorkspaceSymbol { .. }
1708        | Effect::LspIncomingCalls
1709        | Effect::LspOutgoingCalls
1710        | Effect::LspSupertypes
1711        | Effect::LspSubtypes
1712        | Effect::LspMoniker
1713        | Effect::LspCodeLens
1714        | Effect::LspColorPresentation
1715        | Effect::Format
1716        | Effect::LspFormat
1717        | Effect::LspFormatRange
1718        | Effect::LspSignatureHelp
1719        | Effect::LspComplete
1720        | Effect::LspRename { .. }
1721        | Effect::LspCodeAction
1722        | Effect::ExpandSnippet { .. }
1723        | Effect::ReloadSnippets
1724        | Effect::ToggleMode { .. }
1725        | Effect::DescribeEvents
1726        | Effect::DescribeEvent { .. }
1727        | Effect::DescribeDiff
1728        | Effect::DiffOpen
1729        | Effect::DiffOff { .. }
1730        | Effect::Diffthis
1731        | Effect::Diffsplit { .. }
1732        | Effect::DiffGetCmd { .. }
1733        | Effect::DiffPutCmd { .. }
1734        | Effect::DiffAccept
1735        | Effect::DiffReject
1736        | Effect::DiffAcceptAll
1737        | Effect::DiffRejectAll
1738        | Effect::CloseSessionDiffs { .. }
1739        | Effect::CloseAllSessionDiffs { .. }
1740        | Effect::NextHunk
1741        | Effect::PrevHunk
1742        | Effect::ListModes
1743        | Effect::DescribeMode { .. }
1744        | Effect::DescribeActiveModes
1745        | Effect::DescribeActiveBindings
1746        | Effect::DescribeOptionResolution { .. }
1747        | Effect::Customize { .. }
1748        | Effect::Tutor { .. }
1749        | Effect::AppAction(_)
1750        | Effect::RecordJump
1751        | Effect::OpenDashboard
1752        | Effect::ChangeDir(_)
1753        | Effect::PrintWorkingDir
1754        | Effect::PrintProjectRoot => false,
1755    }
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760    use super::*;
1761    use crate::app::test_helpers::{
1762        app_in_command_mode, app_with, attach_test_syntax, invoke_motion, press, press_chars,
1763        subscribe_all_events, write_temp_file,
1764    };
1765
1766    fn lm0_temp_file(name: &str, contents: &str) -> std::path::PathBuf {
1767        let dir = std::env::temp_dir().join(format!(
1768            "lattice-lm0-{}-{}",
1769            std::process::id(),
1770            std::time::SystemTime::now()
1771                .duration_since(std::time::UNIX_EPOCH)
1772                .map(|d| d.as_nanos())
1773                .unwrap_or(0)
1774        ));
1775        std::fs::create_dir_all(&dir).unwrap();
1776        let path = dir.join(name);
1777        std::fs::write(&path, contents).unwrap();
1778        path
1779    }
1780
1781    /// LM.0: `Effect::OpenInTarget { target: Split }` opens the file in a
1782    /// NEW pane — the pane tree grows by one, the new active pane shows the
1783    /// opened file, and the pane the user was in survives. Proves the peer
1784    /// arm runs the picker-accept split sequence, not an in-place open.
1785    /// Asserted on the pane tree, not just the effect (design §8).
1786    #[test]
1787    fn open_in_target_split_opens_in_a_new_pane() {
1788        use lattice_core::ui::pane::OpenTarget;
1789        let target = lm0_temp_file("opened.rs", "fn opened() {}\n");
1790
1791        let mut a = app_with("original\n", 20);
1792        let before_panes = a.editor.pane_tree.len();
1793        let before_buf = a.editor.document_buffer_id;
1794
1795        a.apply_effect(lattice_grammar::Effect::OpenInTarget {
1796            path: Some(target.clone()),
1797            position: lattice_protocol::position::Position::ZERO,
1798            target: OpenTarget::Split,
1799        });
1800
1801        assert_eq!(
1802            a.editor.pane_tree.len(),
1803            before_panes + 1,
1804            "Split target grows the pane tree by one leaf"
1805        );
1806        assert_ne!(
1807            a.editor.document_buffer_id, before_buf,
1808            "the new active pane shows the opened file, not the original buffer"
1809        );
1810        assert!(
1811            a.editor
1812                .pane_tree
1813                .leaves()
1814                .iter()
1815                .any(|p| p.buffer_id == before_buf),
1816            "the original pane still exists after the split-open"
1817        );
1818    }
1819
1820    /// LM.0: `target: Default` behaves like `OpenBufferAt` — opens in the
1821    /// CURRENT pane, no split.
1822    #[test]
1823    fn open_in_target_default_opens_in_place() {
1824        use lattice_core::ui::pane::OpenTarget;
1825        let target = lm0_temp_file("inplace.rs", "fn inplace() {}\n");
1826
1827        let mut a = app_with("original\n", 20);
1828        let before_panes = a.editor.pane_tree.len();
1829
1830        a.apply_effect(lattice_grammar::Effect::OpenInTarget {
1831            path: Some(target.clone()),
1832            position: lattice_protocol::position::Position::ZERO,
1833            target: OpenTarget::Default,
1834        });
1835
1836        assert_eq!(
1837            a.editor.pane_tree.len(),
1838            before_panes,
1839            "Default target does not split — opens in the current pane"
1840        );
1841    }
1842
1843    /// MG.50: the `Open*At` effects carry a position computed **before
1844    /// the target buffer exists**, so it can name an offset the
1845    /// destination line does not have. magit `<CR>` on a deletion row is
1846    /// the real case: the byte offset is measured against the deleted
1847    /// text, while the line it resolves to holds whatever is there now,
1848    /// which may be shorter.
1849    ///
1850    /// Both `OpenBufferAt` and `OpenSyntheticBufferAt` therefore
1851    /// position through `set_cursor_clamped`, not `set_cursor`.
1852    #[test]
1853    fn an_open_at_position_past_end_of_line_is_clamped() {
1854        let mut a = app_with("short\nlonger line here\n", 10);
1855
1856        a.editor
1857            .set_cursor_clamped(lattice_protocol::position::Position::new(0, 40));
1858
1859        assert_eq!(a.editor.cursor.line, 0);
1860        assert_eq!(
1861            a.editor.cursor.byte, 5,
1862            "the caret must land at end-of-line, not past it — a caret \
1863             beyond the line makes the next motion behave oddly"
1864        );
1865    }
1866
1867    /// OR.16: the TUI applies what an off-renderer path queued.
1868    ///
1869    /// The host half of this is `Editor::pending_renderer_effects`: the async
1870    /// picker accept, the fill target and the picker's delete verb have no
1871    /// renderer to hand a renderer-owned effect to, so they queue it rather
1872    /// than drop it (which is how four features shipped dead). This is the
1873    /// other half — the peer picking the queue up beside the tick's signals,
1874    /// through the same arms a keystroke's effects go through.
1875    #[test]
1876    fn a_queued_renderer_effect_is_applied_on_the_next_drain() {
1877        let mut a = app_with("scratch\n", 10);
1878        a.editor
1879            .pending_renderer_effects
1880            .push(Effect::OpenSyntheticBuffer {
1881                name: "*queued-by-a-picker*".to_string(),
1882                mode_id: "help-mode".to_string(),
1883                content: Some("from an off-renderer path\n".to_string()),
1884                cursor: None,
1885                activate_minor: None,
1886            });
1887
1888        a.drain_async_pending();
1889
1890        assert!(
1891            a.editor.buffers.by_name("*queued-by-a-picker*").is_some(),
1892            "the queued effect reached the TUI's own arms"
1893        );
1894        assert!(
1895            a.editor.pending_renderer_effects.is_empty(),
1896            "and the queue is drained, so the next frame does not repeat it"
1897        );
1898    }
1899
1900    /// The clamp must not quietly move a caret that was already valid.
1901    #[test]
1902    fn an_open_at_position_within_the_line_is_untouched() {
1903        let mut a = app_with("short\nlonger line here\n", 10);
1904
1905        a.editor
1906            .set_cursor_clamped(lattice_protocol::position::Position::new(1, 7));
1907
1908        assert_eq!(a.editor.cursor.line, 1);
1909        assert_eq!(a.editor.cursor.byte, 7);
1910    }
1911    use crate::app::test_helpers::{fresh_workspace, write_workspace_config};
1912    use crate::app::word_under_cursor;
1913    use crate::app::*;
1914    use crate::help::HelpContent;
1915    use lattice_protocol::Event;
1916    use lattice_protocol::edit::Edit;
1917
1918    /// Test Guard: drop-counter so tests can verify the
1919    /// Drop-based cleanup contract.
1920    pub struct TestLocalsGuard {
1921        counter: std::sync::Arc<std::sync::atomic::AtomicI64>,
1922    }
1923
1924    impl Drop for TestLocalsGuard {
1925        fn drop(&mut self) {
1926            self.counter.store(0, std::sync::atomic::Ordering::SeqCst);
1927        }
1928    }
1929
1930    struct TestLocalsMode {
1931        id: lattice_mode::ModeId,
1932        counter: std::sync::Arc<std::sync::atomic::AtomicI64>,
1933    }
1934
1935    impl TestLocalsMode {
1936        fn new() -> Self {
1937            Self {
1938                id: lattice_mode::ModeId::new("test-locals-mode"),
1939                counter: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)),
1940            }
1941        }
1942    }
1943
1944    impl lattice_mode::Mode for TestLocalsMode {
1945        type Guard = TestLocalsGuard;
1946        fn id(&self) -> lattice_mode::ModeId {
1947            self.id
1948        }
1949        fn kind(&self) -> lattice_mode::ModeKind {
1950            lattice_mode::ModeKind::Minor
1951        }
1952        fn on_activate(
1953            &self,
1954            _ctx: lattice_mode::ModeContext,
1955        ) -> lattice_mode::LifecycleFuture<'_, TestLocalsGuard> {
1956            self.counter.store(42, std::sync::atomic::Ordering::SeqCst);
1957            let counter = self.counter.clone();
1958            Box::pin(async move { Ok(TestLocalsGuard { counter }) })
1959        }
1960    }
1961
1962    #[test]
1963    fn delete_trailing_word_strips_then_cuts() {
1964        let mut s = String::from("alpha beta");
1965        delete_trailing_word(&mut s);
1966        assert_eq!(s, "alpha ");
1967    }
1968
1969    #[test]
1970    fn delete_trailing_word_handles_only_whitespace() {
1971        let mut s = String::from("   ");
1972        delete_trailing_word(&mut s);
1973        assert_eq!(s, "");
1974    }
1975
1976    #[test]
1977    fn delete_trailing_word_empty_string_is_noop() {
1978        let mut s = String::new();
1979        delete_trailing_word(&mut s);
1980        assert_eq!(s, "");
1981    }
1982
1983    #[test]
1984    fn key_harness_j_advances_cursor_one_line() {
1985        let mut a = app_with("one\ntwo\nthree", 10);
1986        press_chars(&mut a, "j");
1987        assert_eq!(a.editor.cursor.line, 1);
1988    }
1989
1990    #[test]
1991    fn key_harness_dw_deletes_first_word() {
1992        let mut a = app_with("one two three", 10);
1993        press_chars(&mut a, "dw");
1994        assert_eq!(a.editor.document.text(), "two three");
1995    }
1996
1997    #[test]
1998    fn key_harness_count_before_motion_advances_n_lines() {
1999        let mut a = app_with("a\nb\nc\nd\ne", 10);
2000        press_chars(&mut a, "3j");
2001        assert_eq!(a.editor.cursor.line, 3);
2002    }
2003
2004    #[test]
2005    fn key_harness_count_before_operator_dd_deletes_n_lines() {
2006        let mut a = app_with("a\nb\nc\nd\ne", 10);
2007        press_chars(&mut a, "3dd");
2008        assert_eq!(a.editor.document.text(), "d\ne");
2009    }
2010
2011    #[test]
2012    fn key_harness_count_after_operator_d2w_deletes_two_words() {
2013        let mut a = app_with("one two three four", 10);
2014        press_chars(&mut a, "d2w");
2015        assert_eq!(a.editor.document.text(), "three four");
2016    }
2017
2018    #[test]
2019    fn key_harness_counts_multiply_on_both_sides() {
2020        let mut a = app_with("a b c d e f g", 10);
2021        press_chars(&mut a, "2d2w");
2022        assert_eq!(a.editor.document.text(), "e f g");
2023    }
2024
2025    #[test]
2026    fn key_harness_count_clears_after_motion_fires() {
2027        let mut a = app_with("a\nb\nc\nd\ne\nf", 10);
2028        press_chars(&mut a, "3j");
2029        assert_eq!(a.editor.cursor.line, 3);
2030        press_chars(&mut a, "j");
2031        assert_eq!(a.editor.cursor.line, 4);
2032    }
2033
2034    #[test]
2035    fn key_harness_gg_jumps_to_first_line() {
2036        let mut a = app_with("one\ntwo\nthree\nfour", 10);
2037        press_chars(&mut a, "G");
2038        assert_eq!(a.editor.cursor.line, 3);
2039        press_chars(&mut a, "gg");
2040        assert_eq!(a.editor.cursor.line, 0);
2041    }
2042
2043    /// User reported 2026-05-22: `gg` does nothing in the
2044    /// production TUI binary but `press_chars(&mut a, "gg")`
2045    /// (the existing test) passes. Hypothesis: `press_chars` reads
2046    /// `partial_chord` from `&app.editor.partial_chord` directly,
2047    /// but `runtime.rs::main_loop` reads from
2048    /// `app.render_state.load().translator.partial_chord`. If the
2049    /// publish layer is out of sync, production fails while
2050    /// tests pass.
2051    ///
2052    /// This test mimics the production main_loop's exact ctx-build
2053    /// REGRESSION TEST 2026-05-22. User reported gg/dw/zz/zt/zb
2054    /// all broken in production. Root cause: GPUI (and TUI per-tick)
2055    /// dispatches `Action::EnsureCursorVisible` between user
2056    /// keystrokes. Without exempting it from the partial-chord clear
2057    /// guard, EnsureCursorVisible wipes `partial_chord` ~10ms after
2058    /// every ABSORB, so the second keystroke of every chord
2059    /// sequence sees an empty stack.
2060    ///
2061    /// This test simulates the exact production sequence:
2062    ///   keystroke g  →  ABSORB g  →  per-frame EnsureCursorVisible
2063    ///   keystroke g  →  must STILL see partial=[g] in the RS.
2064    ///
2065    /// Before the fix: partial_chord=[] after the inter-keystroke
2066    /// EnsureCursorVisible dispatch. After: partial_chord=[g].
2067    #[test]
2068    fn partial_chord_survives_inter_keystroke_ensure_cursor_visible() {
2069        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2070
2071        let mut a = app_with("one\ntwo\nthree\nfour\nfive", 10);
2072        // First g: absorbs into partial_chord.
2073        press(
2074            &mut a,
2075            KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
2076        );
2077        assert_eq!(
2078            a.editor.partial_chord.len(),
2079            1,
2080            "first `g` should populate partial_chord"
2081        );
2082
2083        // Simulate GPUI's per-frame `ensure_cursor_in_viewport`
2084        // dispatching `Action::EnsureCursorVisible` between
2085        // user keystrokes.
2086        a.apply(lattice_host::action::Action::EnsureCursorVisible);
2087
2088        // CRITICAL: partial_chord must SURVIVE this dispatch.
2089        // Pre-fix: cleared to []. Post-fix: still [g].
2090        assert_eq!(
2091            a.editor.partial_chord.len(),
2092            1,
2093            "EnsureCursorVisible (renderer-housekeeping) must NOT clear partial_chord"
2094        );
2095        assert_eq!(
2096            a.render_state.load().translator.partial_chord.len(),
2097            1,
2098            "Published RS must also retain partial_chord"
2099        );
2100
2101        // Second g resolves gg.
2102        press(
2103            &mut a,
2104            KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
2105        );
2106        assert_eq!(
2107            a.editor.cursor.line, 0,
2108            "gg should jump to line 0; partial_chord survived the inter-keystroke EnsureCursorVisible"
2109        );
2110    }
2111
2112    /// code path — reading `partial_chord` from the published RS.
2113    /// If this test passes too, the divergence is elsewhere; if
2114    /// it fails, we've localised the bug to the publish layer.
2115    #[test]
2116    fn gg_through_published_rs_matches_runtime_main_loop() {
2117        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2118
2119        let mut a = app_with("one\ntwo\nthree\nfour\nfive", 10);
2120        // Park cursor on last line via `G` so gg has visible work.
2121        for _ in 0..4 {
2122            press_chars(&mut a, "j");
2123        }
2124        assert_eq!(a.editor.cursor.line, 4, "cursor parks on last line");
2125
2126        // Press `g` twice using the SAME ctx-build code path
2127        // runtime.rs main_loop uses. Reads partial_chord from
2128        // published RS each time.
2129        fn press_g_via_rs(a: &mut App) {
2130            let ad = a.ad();
2131            let translator = a.render_state.load().translator.clone();
2132            let ctx = crate::input::TranslateContext {
2133                modal: ad.modal,
2134                builtins: &translator.builtins,
2135                pending_count: ad.pending_count,
2136                op_count: ad.op_count,
2137                recording_macro: ad.macro_recording,
2138                active_buffer: ad.buffer_kind,
2139                completion_open: ad.completion_open,
2140                chord_capture: a.chord_capture_active(),
2141                picker_open: ad.picker_open,
2142                insert_completion_open: a.completion_popup_active(),
2143                snippet_active: ad.snippet_active,
2144                terminal_insert_active: ad.terminal_insert_active,
2145                terminal_esc_exits: ad.terminal_esc_exits,
2146                terminal_app_cursor_keys: ad.terminal_app_cursor_keys,
2147                terminal_insert_exit_pending: ad.terminal_insert_exit_pending,
2148                terminal_visual_active: ad.terminal_visual_active,
2149                keymap: &translator.keymap,
2150                partial_chord: &translator.partial_chord,
2151                active_minor_modes: &translator.active_minor_modes,
2152            };
2153            let action =
2154                crate::input::translate(ctx, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
2155            a.apply(action);
2156        }
2157        press_g_via_rs(&mut a);
2158        // After first `g`: partial_chord should be [g] in BOTH
2159        // sources (editor field AND published RS).
2160        assert_eq!(
2161            a.editor.partial_chord.len(),
2162            1,
2163            "editor.partial_chord populated after first g"
2164        );
2165        assert_eq!(
2166            a.render_state.load().translator.partial_chord.len(),
2167            1,
2168            "RS.translator.partial_chord populated after first g — \
2169             this is what runtime.rs reads"
2170        );
2171        press_g_via_rs(&mut a);
2172        assert_eq!(
2173            a.editor.cursor.line, 0,
2174            "gg via production code path should jump to line 0"
2175        );
2176    }
2177
2178    #[test]
2179    fn key_harness_df_delim_deletes_through_match() {
2180        // `f` is inclusive: `df,` deletes through the first comma, leaving
2181        // " beta, gamma" (vim parity).
2182        let mut a = app_with("alpha, beta, gamma", 10);
2183        press_chars(&mut a, "df,");
2184        assert_eq!(a.editor.document.text(), " beta, gamma");
2185    }
2186
2187    #[test]
2188    fn key_harness_ctrl_w_v_creates_second_pane() {
2189        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2190        let mut a = app_with("xx", 10);
2191        press(
2192            &mut a,
2193            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
2194        );
2195        press_chars(&mut a, "v");
2196        assert_eq!(a.editor.pane_tree.len(), 2);
2197    }
2198
2199    #[test]
2200    fn key_harness_insert_round_trip_inserts_text_and_returns_normal() {
2201        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2202        let mut a = app_with("", 10);
2203        press_chars(&mut a, "ihi");
2204        press(&mut a, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2205        assert_eq!(a.editor.document.text(), "hi");
2206        assert_eq!(a.editor.modal, ModalState::Normal);
2207    }
2208
2209    #[test]
2210    fn x_deletes_whole_multibyte_char_without_panicking() {
2211        // Regression: `x` (delete + char_right) over a multibyte glyph
2212        // must delete the WHOLE scalar, not one byte. The AI conversation
2213        // transcript is seeded with `│` (U+2502, 3 bytes) etc.; a byte-step
2214        // motion produced a delete range ending mid-char, panicking ropey
2215        // on a non-char-boundary byte slice. See systematic-debugging trace.
2216        let mut a = app_with("│x", 10);
2217        press_chars(&mut a, "x");
2218        assert_eq!(a.editor.document.text(), "x");
2219    }
2220
2221    #[test]
2222    fn event_bus_publishes_document_changed_on_apply_edit() {
2223        let mut a = app_with("hello", 5);
2224        let mut rx = subscribe_all_events(&a);
2225        a.apply_edit_blocking(Edit::insert(Position::new(0, 5), " world"))
2226            .unwrap();
2227        assert!(matches!(rx.try_recv(), Ok(Event::DocumentChanged { .. })));
2228    }
2229
2230    #[test]
2231    fn apply_edit_accumulates_delta_when_syntax_attached() {
2232        let mut a = app_with("hello", 5);
2233        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
2234        assert_eq!(a.editor.pending_syntax_edits.len(), 0);
2235        a.apply_edit_blocking(Edit::insert(Position::new(0, 5), " world"))
2236            .unwrap();
2237        assert_eq!(a.editor.pending_syntax_edits.len(), 1);
2238        let delta = a.editor.pending_syntax_edits[0];
2239        assert_eq!(delta.start_byte, 5);
2240        assert_eq!(delta.old_end_byte, 5);
2241        assert_eq!(delta.new_end_byte, 11);
2242    }
2243
2244    #[test]
2245    fn apply_edit_skips_delta_accumulation_when_no_syntax() {
2246        // No syntax attached -> publish_document_changed
2247        // short-circuits the delta push to keep the vec bounded.
2248        let mut a = app_with("hello", 5);
2249        assert!(a.editor.syntax.is_none());
2250        a.apply_edit_blocking(Edit::insert(Position::new(0, 5), " world"))
2251            .unwrap();
2252        assert_eq!(a.editor.pending_syntax_edits.len(), 0);
2253    }
2254
2255    #[test]
2256    fn apply_edit_batch_accumulates_one_delta_per_edit() {
2257        let mut a = app_with("abc", 5);
2258        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
2259        let edits = vec![
2260            Edit::insert(Position::new(0, 0), "1"),
2261            Edit::insert(Position::new(0, 2), "2"),
2262        ];
2263        a.apply_edit_batch_blocking(edits).unwrap();
2264        assert_eq!(a.editor.pending_syntax_edits.len(), 2);
2265    }
2266
2267    #[test]
2268    fn event_bus_publishes_document_changed_on_undo_redo() {
2269        let mut a = app_with("a", 5);
2270        a.apply_edit_blocking(Edit::insert(Position::new(0, 1), "b"))
2271            .unwrap();
2272        let mut rx = subscribe_all_events(&a);
2273        a.undo_blocking().unwrap();
2274        a.redo_blocking().unwrap();
2275        let mut count = 0;
2276        while let Ok(Event::DocumentChanged { .. }) = rx.try_recv() {
2277            count += 1;
2278        }
2279        assert_eq!(count, 2, "expected DocumentChanged for undo + redo");
2280    }
2281
2282    #[test]
2283    fn event_bus_publishes_modal_mode_changed_on_actual_transition() {
2284        let mut a = app_with("", 5);
2285        let mut rx = subscribe_all_events(&a);
2286        a.apply(Action::EnterMode(ModalState::Insert));
2287        let evt = rx.try_recv().unwrap();
2288        match evt {
2289            Event::ModalModeChanged { from, to } => {
2290                assert_eq!(from, "Normal");
2291                assert_eq!(to, "Insert");
2292            }
2293            other => panic!("expected ModalModeChanged, got {other:?}"),
2294        }
2295    }
2296
2297    #[test]
2298    fn event_bus_skips_modal_mode_changed_when_state_unchanged() {
2299        // enter_mode is sometimes called for the side-effect of
2300        // recording / replay accounting without actually moving
2301        // the modal axis. Those re-entries shouldn't fire events.
2302        let mut a = app_with("", 5);
2303        let mut rx = subscribe_all_events(&a);
2304        a.apply(Action::EnterMode(ModalState::Normal)); // Normal -> Normal
2305        assert!(rx.try_recv().is_err(), "no event for same-state re-entry");
2306    }
2307
2308    #[test]
2309    fn event_bus_publishes_before_quit_on_action_quit() {
2310        let mut a = app_with("", 5);
2311        let mut rx = subscribe_all_events(&a);
2312        a.apply(Action::Quit);
2313        // Drain until BeforeQuit (other events may precede).
2314        let mut found = false;
2315        while let Ok(evt) = rx.try_recv() {
2316            if matches!(evt, Event::BeforeQuit) {
2317                found = true;
2318                break;
2319            }
2320        }
2321        assert!(found, "BeforeQuit should be published on Action::Quit");
2322        assert!(a.editor.should_quit);
2323    }
2324
2325    #[test]
2326    fn event_bus_publishes_selections_changed_on_set_selections() {
2327        let mut a = app_with("hello world", 5);
2328        let mut rx = subscribe_all_events(&a);
2329        let sel = Selection::cursor(Position::new(0, 5));
2330        a.editor.set_selections_blocking(SelectionSet::single(sel));
2331        let mut found = false;
2332        while let Ok(evt) = rx.try_recv() {
2333            if matches!(evt, Event::SelectionsChanged { .. }) {
2334                found = true;
2335                break;
2336            }
2337        }
2338        assert!(found);
2339    }
2340
2341    #[test]
2342    fn invocation_resets_partial_chord() {
2343        // Slice 8.i.4: AbsorbPartialChord pushes onto
2344        // partial_chord; any other action clears it.
2345        let mut a = app_with("abc", 10);
2346        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
2347            'g',
2348        )));
2349        assert_eq!(a.editor.partial_chord.len(), 1);
2350        let id = a.editor.builtins.char_right;
2351        a.apply(invoke_motion(id));
2352        assert!(a.editor.partial_chord.is_empty());
2353    }
2354
2355    /// Regression (2026-06-03, `gjcg` → `gg` bug): an UNBOUND chord
2356    /// continuation translates to `Action::None`, and that must
2357    /// CLEAR the pending prefix — otherwise `[g]` stays latched and
2358    /// every following non-`g`-continuation key is swallowed until a
2359    /// valid one completes (e.g. `gg`). `Action::None` was wrongly
2360    /// exempted as renderer-housekeeping; it is not.
2361    #[test]
2362    fn unbound_continuation_clears_partial_chord() {
2363        let mut a = app_with("abc", 10);
2364        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
2365            'g',
2366        )));
2367        assert_eq!(a.editor.partial_chord.len(), 1, "`g` latched the prefix");
2368        // `gj` is unbound ⇒ translate returns `Action::None`.
2369        a.apply(Action::None);
2370        assert!(
2371            a.editor.partial_chord.is_empty(),
2372            "an unbound continuation must cancel the pending prefix"
2373        );
2374    }
2375
2376    /// The complement: genuine renderer-housekeeping actions that
2377    /// fire BETWEEN keystrokes must NOT clear a pending prefix
2378    /// (the 2026-05-22 fix this must not regress).
2379    #[test]
2380    fn housekeeping_preserves_partial_chord() {
2381        let mut a = app_with("abc", 10);
2382        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
2383            'g',
2384        )));
2385        a.apply(Action::EnsureCursorVisible);
2386        assert_eq!(
2387            a.editor.partial_chord.len(),
2388            1,
2389            "per-frame EnsureCursorVisible must not drop the pending `g`"
2390        );
2391    }
2392
2393    #[test]
2394    fn entering_insert_mode_does_not_move_cursor() {
2395        let mut a = app_with("abc", 10);
2396        let before = a.editor.cursor;
2397        a.apply(Action::EnterMode(ModalState::Insert));
2398        assert_eq!(a.editor.modal, ModalState::Insert);
2399        assert_eq!(a.editor.cursor, before);
2400    }
2401
2402    // ---- PBH.3: `<C-6>` / `<C-7>` pane buffer history ----
2403
2404    /// The chord must actually DISPATCH, not be swallowed.
2405    ///
2406    /// Driven with `press()` rather than `apply(Action::…)` on purpose:
2407    /// a `BindingMode` arm that never routes the key is invisible to a
2408    /// handler-level test, which would pass on a completely unbound
2409    /// chord. The echo is the proof the action ran — a swallowed key
2410    /// produces no message at all.
2411    ///
2412    /// `<C-6>` is the spelling terminals actually deliver: they send
2413    /// 0x1E, and crossterm maps `b'\x1C'..=b'\x1F'` to
2414    /// `Char('4'..'7') + CONTROL`.
2415    #[test]
2416    fn ctrl_6_dispatches_pane_history_back() {
2417        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2418        let mut a = app_with("hello\n", 5);
2419        press(
2420            &mut a,
2421            KeyEvent::new(KeyCode::Char('6'), KeyModifiers::CONTROL),
2422        );
2423        let msg = a
2424            .editor
2425            .last_message
2426            .as_ref()
2427            .expect("<C-6> must reach the action; no echo means the key was swallowed");
2428        assert!(
2429            msg.text.contains("oldest"),
2430            "expected the at-the-end echo, got {:?}",
2431            msg.text,
2432        );
2433    }
2434
2435    /// Same for the forward chord (0x1F → `Char('7') + CONTROL`).
2436    #[test]
2437    fn ctrl_7_dispatches_pane_history_forward() {
2438        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2439        let mut a = app_with("hello\n", 5);
2440        press(
2441            &mut a,
2442            KeyEvent::new(KeyCode::Char('7'), KeyModifiers::CONTROL),
2443        );
2444        let msg = a
2445            .editor
2446            .last_message
2447            .as_ref()
2448            .expect("<C-7> must reach the action");
2449        assert!(
2450            msg.text.contains("newest"),
2451            "expected the at-the-end echo, got {:?}",
2452            msg.text,
2453        );
2454    }
2455
2456    /// The two chords are distinct actions — a regression that folded
2457    /// them together would make the pair useless while still echoing.
2458    #[test]
2459    fn ctrl_6_and_ctrl_7_are_distinct() {
2460        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2461        let mut a = app_with("hello\n", 5);
2462        press(
2463            &mut a,
2464            KeyEvent::new(KeyCode::Char('6'), KeyModifiers::CONTROL),
2465        );
2466        let back = a.editor.last_message.as_ref().expect("echo").text.clone();
2467        press(
2468            &mut a,
2469            KeyEvent::new(KeyCode::Char('7'), KeyModifiers::CONTROL),
2470        );
2471        let fwd = a.editor.last_message.as_ref().expect("echo").text.clone();
2472        assert_ne!(back, fwd, "<C-6> and <C-7> must not collapse");
2473    }
2474
2475    #[test]
2476    fn ctrl_o_then_ctrl_i_round_trips() {
2477        let mut a = app_with("a\nb\nc\nd\ne", 10);
2478        a.editor.cursor = Position::new(2, 0);
2479        a.apply(invoke_motion(a.editor.builtins.goto_first_line));
2480        // Now at line 0; jump list has [(2,0)] cursor at end.
2481        a.apply(Action::JumpHistoryBack);
2482        assert_eq!(a.editor.cursor, Position::new(2, 0));
2483        a.apply(Action::JumpHistoryForward);
2484        assert_eq!(a.editor.cursor, Position::ZERO);
2485    }
2486
2487    #[test]
2488    fn invocation_with_no_pending_register_uses_unnamed() {
2489        let mut a = app_with("hello world", 10);
2490        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
2491            lattice_grammar::Target::Motion(
2492                a.editor.builtins.word_forward,
2493                lattice_grammar::Args::None,
2494            ),
2495        );
2496        a.apply(Action::Invoke(inv));
2497        // Unnamed populated; "0 also populated by vim's auto-fill on yank.
2498        // Named map's only entry is the numbered "0 register.
2499        assert!(a.editor.unnamed_register.is_some());
2500        assert!(a.editor.registers.contains_key(&Register::Numbered(0)));
2501        // No alphabetic named slots populated.
2502        assert!(
2503            !a.editor
2504                .registers
2505                .keys()
2506                .any(|r| matches!(r, Register::Named(_)))
2507        );
2508    }
2509
2510    #[test]
2511    fn enter_replace_sets_modal() {
2512        let mut a = app_with("hello", 10);
2513        a.apply(Action::EnterMode(ModalState::Replace));
2514        assert_eq!(a.editor.modal, ModalState::Replace);
2515    }
2516
2517    #[test]
2518    fn enter_replace_clears_replace_history() {
2519        let mut a = app_with("hello", 10);
2520        a.apply(Action::EnterMode(ModalState::Replace));
2521        a.apply(Action::OverwriteChar('H'));
2522        assert_eq!(a.editor.replace_history.len(), 1);
2523        a.apply(Action::EnterMode(ModalState::Normal));
2524        a.apply(Action::EnterMode(ModalState::Replace));
2525        assert!(a.editor.replace_history.is_empty());
2526    }
2527
2528    #[test]
2529    fn dot_with_no_prior_change_emits_error() {
2530        let mut a = app_with("hello", 10);
2531        assert!(a.editor.last_change.is_none());
2532        a.apply(Action::RepeatLastChange);
2533        let msg = a.editor.last_message.as_ref().unwrap();
2534        assert_eq!(msg.level, EchoLevel::Error);
2535    }
2536
2537    #[test]
2538    fn dd_records_last_change_and_dot_replays_it() {
2539        let mut a = app_with("aaa\nBBB\nccc\nddd", 10);
2540        a.editor.cursor = Position::new(1, 0);
2541        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2542            .with_range(lattice_grammar::Range::CurrentLine);
2543        a.apply(Action::Invoke(inv));
2544        // Slice 8.i.4.g: `dd` consumes BBB + its trailing newline.
2545        assert_eq!(a.editor.document.text(), "aaa\nccc\nddd");
2546        // Cursor is now on what used to be `ccc` (line 1). `.`
2547        // repeats the linewise delete -- removes that line + its
2548        // trailing newline.
2549        a.apply(Action::RepeatLastChange);
2550        assert_eq!(a.editor.document.text(), "aaa\nddd");
2551    }
2552
2553    #[test]
2554    fn dot_repeats_change_with_insert_replay() {
2555        // Classic vim test: cw foo<Esc> followed by . on another word
2556        // replaces that word with "foo" too.
2557        let mut a = app_with("alpha beta gamma", 10);
2558        // cw on first word.
2559        let inv = CommandInvocation::of(a.editor.builtins.change.0).with_target(
2560            lattice_grammar::Target::Motion(
2561                a.editor.builtins.word_forward,
2562                lattice_grammar::Args::None,
2563            ),
2564        );
2565        a.apply(Action::Invoke(inv));
2566        a.apply(Action::Insert("X".into()));
2567        a.apply(Action::EnterMode(ModalState::Normal));
2568        assert_eq!(a.editor.document.text(), "Xbeta gamma");
2569        // Move to "beta" (cursor is now on 'X' / position 0; let's go to 'b'
2570        // at byte 1).
2571        a.editor.cursor = Position::new(0, 1);
2572        // Repeat.
2573        a.apply(Action::RepeatLastChange);
2574        // cw replays: deletes "beta " and inserts "X" -> "XXgamma".
2575        // (Note: our cw includes the trailing space; vim's cw is implicitly
2576        // ce, a deferred refinement.)
2577        assert_eq!(a.editor.document.text(), "XXgamma");
2578        assert_eq!(a.editor.modal, ModalState::Normal);
2579    }
2580
2581    #[test]
2582    fn dot_without_insert_replay_when_no_text_was_typed() {
2583        // dw (no insert phase) -> . repeats just the delete.
2584        let mut a = app_with("alpha beta gamma", 10);
2585        let inv = CommandInvocation::of(a.editor.builtins.delete.0).with_target(
2586            lattice_grammar::Target::Motion(
2587                a.editor.builtins.word_forward,
2588                lattice_grammar::Args::None,
2589            ),
2590        );
2591        a.apply(Action::Invoke(inv));
2592        // dw deletes "alpha "; then `.` deletes another word (no insert).
2593        a.apply(Action::RepeatLastChange);
2594        // Two dws: "alpha " then "beta " -> "gamma".
2595        assert_eq!(a.editor.document.text(), "gamma");
2596    }
2597
2598    #[test]
2599    fn dispatcher_runs_counted_motion() {
2600        // Slice 8.i.4.f: count multiplication is input-side. The
2601        // dispatcher consumes the baked `inv.count` -- App still
2602        // resets `pending_count` at end-of-dispatch (drained by
2603        // attach_count earlier in the pipeline). Press-harness
2604        // tests cover the full keystroke flow.
2605        let mut a = app_with("one two three four five", 10);
2606        a.editor.pending_count = 3;
2607        a.apply(Action::Invoke(
2608            CommandInvocation::of(a.editor.builtins.word_forward.0)
2609                .with_count(lattice_grammar::command::Count(3)),
2610        ));
2611        // 3w from origin: "one two three FOUR five" -> 'f' of "four" at byte 14.
2612        assert_eq!(a.editor.cursor, Position::new(0, 14));
2613        // pending_count is reset after dispatch.
2614        assert_eq!(a.editor.pending_count, 0);
2615    }
2616
2617    #[test]
2618    fn dispatcher_runs_counted_operator_on_motion_2dw() {
2619        let mut a = app_with("one two three four five", 10);
2620        // Mirror translate-time state: `2d` already absorbed.
2621        a.editor.op_count = 2;
2622        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2623            .with_target(lattice_grammar::Target::Motion(
2624                a.editor.builtins.word_forward,
2625                lattice_grammar::Args::None,
2626            ))
2627            .with_count(lattice_grammar::command::Count(2));
2628        a.apply(Action::Invoke(inv));
2629        // 2dw: deletes "one two " leaving "three four five".
2630        assert_eq!(a.editor.document.text(), "three four five");
2631        assert_eq!(a.editor.op_count, 0);
2632    }
2633
2634    #[test]
2635    fn dispatcher_runs_counted_operator_on_motion_2d3w_equals_count_6() {
2636        let mut a = app_with("a b c d e f g h i j", 10);
2637        a.editor.op_count = 2;
2638        a.editor.pending_count = 3;
2639        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2640            .with_target(lattice_grammar::Target::Motion(
2641                a.editor.builtins.word_forward,
2642                lattice_grammar::Args::None,
2643            ))
2644            .with_count(lattice_grammar::command::Count(6));
2645        a.apply(Action::Invoke(inv));
2646        // 6 words deleted from "a b c d e f g h i j" leaves "g h i j".
2647        assert_eq!(a.editor.document.text(), "g h i j");
2648    }
2649
2650    #[test]
2651    fn yy_populates_register_linewise() {
2652        let mut a = app_with("aaa\nBBB\nccc", 10);
2653        a.editor.cursor = Position::new(1, 0);
2654        let inv = CommandInvocation::of(a.editor.builtins.yank.0)
2655            .with_range(lattice_grammar::Range::CurrentLine);
2656        a.apply(Action::Invoke(inv));
2657        let reg = a.editor.unnamed_register.as_ref().unwrap();
2658        assert_eq!(reg.content, "BBB\n");
2659        assert_eq!(reg.kind, YankKind::Linewise);
2660        assert_eq!(a.editor.document.text(), "aaa\nBBB\nccc");
2661    }
2662
2663    #[test]
2664    fn dd_populates_register_linewise_via_delete() {
2665        // delete also yanks; register kind is linewise for dd.
2666        let mut a = app_with("aaa\nBBB\nccc", 10);
2667        a.editor.cursor = Position::new(1, 0);
2668        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2669            .with_range(lattice_grammar::Range::CurrentLine);
2670        a.apply(Action::Invoke(inv));
2671        let reg = a.editor.unnamed_register.as_ref().unwrap();
2672        assert_eq!(reg.kind, YankKind::Linewise);
2673        assert_eq!(reg.content, "BBB\n");
2674    }
2675
2676    #[test]
2677    fn dd_on_closed_fold_heading_deletes_whole_fold() {
2678        // `docs/user/folding.md`: dd on a closed fold deletes the
2679        // entire fold range as a single undo unit. Use a sibling
2680        // # H2 heading so the # H1 fold has a bounded end.
2681        let initial = "# H1\nbody one\nbody two\n# H2\nafter\n";
2682        let mut a = app_with(initial, 10);
2683        a.set_foldmethod_for_test(FoldMethod::Markdown);
2684        a.recompute_folds();
2685        // Close the H1 fold (lines 0..=2).
2686        let idx = a
2687            .editor
2688            .folds
2689            .iter()
2690            .position(|f| f.start_line == 0)
2691            .expect("H1 fold");
2692        a.editor.folds[idx].closed = true;
2693        a.editor.cursor = Position::new(0, 0);
2694        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2695            .with_range(lattice_grammar::Range::CurrentLine);
2696        a.apply(Action::Invoke(inv));
2697        let text = a.editor.document.text();
2698        assert!(!text.contains("# H1"), "H1 not deleted: {text:?}");
2699        assert!(!text.contains("body one"), "body one not deleted: {text:?}");
2700        assert!(!text.contains("body two"), "body two not deleted: {text:?}");
2701        assert!(text.contains("# H2"), "H2 lost: {text:?}");
2702        assert!(text.contains("after"), "after lost: {text:?}");
2703    }
2704
2705    #[test]
2706    fn yy_on_closed_fold_heading_yanks_whole_fold() {
2707        let initial = "# H1\nbody one\nbody two\n# H2\nafter\n";
2708        let mut a = app_with(initial, 10);
2709        a.set_foldmethod_for_test(FoldMethod::Markdown);
2710        a.recompute_folds();
2711        let idx = a
2712            .editor
2713            .folds
2714            .iter()
2715            .position(|f| f.start_line == 0)
2716            .expect("H1 fold");
2717        a.editor.folds[idx].closed = true;
2718        a.editor.cursor = Position::new(0, 0);
2719        let inv = CommandInvocation::of(a.editor.builtins.yank.0)
2720            .with_range(lattice_grammar::Range::CurrentLine);
2721        a.apply(Action::Invoke(inv));
2722        let reg = a.editor.unnamed_register.as_ref().unwrap();
2723        assert_eq!(reg.kind, YankKind::Linewise);
2724        assert!(
2725            reg.content.contains("# H1"),
2726            "register content: {:?}",
2727            reg.content
2728        );
2729        assert!(
2730            reg.content.contains("body one"),
2731            "register content: {:?}",
2732            reg.content
2733        );
2734        assert!(
2735            reg.content.contains("body two"),
2736            "register content: {:?}",
2737            reg.content
2738        );
2739        assert!(
2740            !reg.content.contains("# H2"),
2741            "yank should not include sibling heading: {:?}",
2742            reg.content
2743        );
2744    }
2745
2746    #[test]
2747    fn dd_on_open_fold_heading_deletes_only_one_line() {
2748        // Operator expansion only applies when the fold is *closed*;
2749        // an open fold leaves the heading visible to be edited like
2750        // any other line.
2751        let initial = "# H1\nbody one\nbody two\n# H2\nafter\n";
2752        let mut a = app_with(initial, 10);
2753        a.set_foldmethod_for_test(FoldMethod::Markdown);
2754        a.recompute_folds();
2755        // Leave open (default).
2756        a.editor.cursor = Position::new(0, 0);
2757        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2758            .with_range(lattice_grammar::Range::CurrentLine);
2759        a.apply(Action::Invoke(inv));
2760        let text = a.editor.document.text();
2761        assert!(!text.contains("# H1"), "heading should be gone: {text:?}");
2762        assert!(
2763            text.contains("body one"),
2764            "body one should remain: {text:?}"
2765        );
2766    }
2767
2768    #[test]
2769    fn dd_on_non_fold_line_uses_count_one() {
2770        // Sanity: the fold-expansion only kicks in when the cursor
2771        // is on a closed-fold heading. A normal `dd` outside any
2772        // fold operates on just one line. Slice 8.i.4.g: `dd`
2773        // consumes BBB and its trailing newline (vim semantics);
2774        // the linewise register content carries the `\n` so paste
2775        // splices cleanly.
2776        let mut a = app_with("aaa\nBBB\nccc", 10);
2777        a.set_foldmethod_for_test(FoldMethod::Indent);
2778        a.recompute_folds();
2779        a.editor.cursor = Position::new(1, 0);
2780        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2781            .with_range(lattice_grammar::Range::CurrentLine);
2782        a.apply(Action::Invoke(inv));
2783        let reg = a.editor.unnamed_register.as_ref().unwrap();
2784        assert_eq!(reg.kind, YankKind::Linewise);
2785        assert_eq!(reg.content, "BBB\n");
2786    }
2787
2788    #[test]
2789    fn second_tab_advances_selected_candidate() {
2790        let mut a = app_in_command_mode("descri");
2791        a.apply(Action::CommandLineCompleteOrAdvance);
2792        let first = a.editor.completion_state.as_ref().unwrap().selected;
2793        a.apply(Action::CommandLineCompleteOrAdvance);
2794        let second = a.editor.completion_state.as_ref().unwrap().selected;
2795        assert_eq!(first, 0);
2796        assert_eq!(second, 1);
2797    }
2798
2799    /// DK.2 (2026-09-07): chord arguments are still SEQUENCES, but the KEYMAP
2800    /// TRIE ends them, not an explicit `<CR>`.
2801    ///
2802    /// K.3.5.fix required the user to press `<CR>`, which meant capture had to
2803    /// reserve `<CR>` — and `<Esc>` and `<BS>` beside it — so those three keys
2804    /// could never be described. The trie already knows the difference between
2805    /// "waiting for more" and "this is the answer", so it decides instead.
2806    ///
2807    /// This test previously asserted the opposite ("no submit fired") and is
2808    /// the pin for the reversal: `g` alone is a prefix and must NOT submit;
2809    /// the second `g` completes `gg` and must.
2810    #[test]
2811    fn chord_capture_submits_when_the_trie_says_the_sequence_is_done() {
2812        let mut a = app_in_command_mode("describe-key");
2813        a.apply(Action::CommandLineSubmit);
2814        // Prompt armed: chord-capture overlay active, cmdline pre-filled.
2815        assert!(a.editor.auto_submit_after_chord);
2816        assert_eq!(a.editor.command_line(), "describe-key ");
2817        // `g` is a PREFIX — capture must keep reading rather than describe it.
2818        a.apply(Action::CommandLineAppendChord("g".into()));
2819        assert!(matches!(a.editor.modal, ModalState::Command));
2820        assert_eq!(a.editor.command_line(), "describe-key g");
2821        // The second `g` completes `gg`, so capture submits itself — no `<CR>`.
2822        a.apply(Action::CommandLineAppendChord("g".into()));
2823        assert!(
2824            matches!(a.editor.modal, ModalState::Normal),
2825            "a completed sequence submits without a terminator keystroke"
2826        );
2827        assert!(a.editor.popup_buffer.is_some(), "the help buffer opened");
2828    }
2829
2830    #[test]
2831    fn ctrl_u_clears_command_line_and_dismisses_popup() {
2832        let mut a = app_in_command_mode("foo bar baz");
2833        a.apply(Action::CommandLineCompleteOrAdvance);
2834        a.apply(Action::CommandLineClear);
2835        assert_eq!(a.editor.command_line(), "");
2836        assert!(a.editor.completion_state.is_none());
2837    }
2838
2839    #[test]
2840    fn ctrl_w_deletes_trailing_word() {
2841        let mut a = app_in_command_mode("foo bar baz");
2842        a.apply(Action::CommandLineDeleteWordBackward);
2843        assert_eq!(a.editor.command_line(), "foo bar ");
2844    }
2845
2846    #[test]
2847    fn ctrl_w_with_trailing_whitespace_strips_word() {
2848        let mut a = app_in_command_mode("foo bar  ");
2849        a.apply(Action::CommandLineDeleteWordBackward);
2850        assert_eq!(a.editor.command_line(), "foo ");
2851    }
2852
2853    #[test]
2854    fn ctrl_w_on_single_word_clears() {
2855        let mut a = app_in_command_mode("foo");
2856        a.apply(Action::CommandLineDeleteWordBackward);
2857        assert_eq!(a.editor.command_line(), "");
2858    }
2859
2860    #[test]
2861    fn entering_command_line_dismisses_open_help() {
2862        // Q16: opening `:` dismisses State-A help popups (active is
2863        // still Document; hover-style overlay). State-B popups
2864        // (active = Help, focus moved into the popup) survive --
2865        // the cmdline is part of the focused popup buffer.
2866        let mut a = app_with("xx", 10);
2867        // Simulate State A: register a popup buffer + set the slot
2868        // but leave `active_buffer` on Document. PU.1a: help content
2869        // is an actor-backed Document via `register_help_document`.
2870        let content = crate::help::HelpContent::from_lines("preexisting", vec!["x".into()]);
2871        let id = a.editor.register_help_document(
2872            content,
2873            crate::buffers::BufferFlags {
2874                listed: false,
2875                hidden: true,
2876                ephemeral: false,
2877            },
2878        );
2879        a.editor.popup_buffer = Some(id);
2880        a.apply(Action::EnterCommandLine);
2881        assert!(a.editor.popup_buffer.is_none());
2882    }
2883
2884    #[test]
2885    fn entering_command_line_dismisses_open_completion() {
2886        let mut a = app_with("xx", 10);
2887        a.editor.completion_state = Some(CompletionState {
2888            candidates: Vec::new(),
2889            selected: 0,
2890            replace_start: 0,
2891            original_line: String::new(),
2892        });
2893        a.apply(Action::EnterCommandLine);
2894        assert!(a.editor.completion_state.is_none());
2895    }
2896
2897    #[test]
2898    fn ctrl_l_redraws_screen_and_forces_clean_rederive() {
2899        // `<C-l>` / `:redraw` is the user-visible escape hatch for
2900        // visual glitches. DR.2 (decoration-retention): a routine focus
2901        // change recomputes NO decorations, but this forceful,
2902        // user-initiated action deliberately re-derives from a clean
2903        // slate. It must:
2904        // - flag the runtime to clear the terminal on next frame;
2905        // - reset every visible pane's cell/display matrix to empty +
2906        //   bump `last_parsed_text_version` so the end-of-apply
2907        //   `maybe_reparse_syntax` re-syncs the tree and the cells
2908        //   worker rebuilds each pane's matrix from it on its next wake;
2909        // - echo "redraw" to the user.
2910        let mut a = app_with("fn main() {}\n", 10);
2911        a.editor.pending_redraw = false;
2912        a.apply(Action::RedrawScreen);
2913        assert!(
2914            a.editor.pending_redraw,
2915            "runtime should clear terminal next frame"
2916        );
2917        // Post-apply, the version mirror equals the document's
2918        // version because the end-of-apply reparse already ran.
2919        // The intermediate `u64::MAX` value is gone; that's the
2920        // desired flow -- a forceful redraw produces an already-fresh
2921        // tree, exactly as a single keystroke does.
2922        assert_eq!(
2923            a.editor.last_parsed_text_version,
2924            a.editor.document.text_version(),
2925            "post-apply reparse must have synced the version mirror"
2926        );
2927        let msg = a.editor.last_message.as_ref().expect("info echo");
2928        assert!(msg.text.contains("redraw"), "user-visible echo: {msg:?}");
2929    }
2930
2931    #[test]
2932    fn second_hover_request_focuses_into_popup() {
2933        // First K opens the popup (State A: cursor in doc); second
2934        // K transfers focus into the popup (State B: cursor in
2935        // help). The buffer content is the same; only `active_buffer`
2936        // and the cursor position change. FS.6: the pre-State-B origin is
2937        // captured as a FOCUS FRAME so dismiss restores cleanly — it used
2938        // to be a `prev_pane_for_popup` stash written alongside the frame,
2939        // and dismiss ran both.
2940        let mut a = app_with("fn main() {}\n", 5);
2941        a.do_open_hover("hover body line 1\nhover body line 2");
2942        assert!(a.editor.popup_buffer.is_some());
2943        assert!(matches!(a.editor.active_buffer, BufferKind::Document));
2944        assert!(a.editor.prev_pane_for_popup.is_none());
2945        // Second K -> focus into popup.
2946        // 5.5.LSP.1: hover request migrated to `Editor::dispatch`;
2947        // exercise the State A -> State B promote through the Action
2948        // path so the test covers the live dispatch wire too.
2949        a.apply_effect(lattice_grammar::Effect::Lsp(
2950            lattice_grammar::LspRequest::Hover,
2951        ));
2952        assert!(
2953            a.editor.popup_buffer.is_some(),
2954            "popup stays up after focus"
2955        );
2956        assert!(matches!(a.editor.active_buffer, BufferKind::Help));
2957        let frame = a
2958            .editor
2959            .focused_surface()
2960            .expect("State B captures the origin as a focus frame");
2961        assert_eq!(frame.prior_active_buffer, BufferKind::Document);
2962        assert!(
2963            a.editor.prev_pane_for_popup.is_none(),
2964            "and only the frame — the stash is not written in parallel"
2965        );
2966    }
2967
2968    #[test]
2969    fn switching_back_to_buffer_preserves_closed_fold_state() {
2970        // Open two buffers with foldmethod=indent. Close a fold in
2971        // the first, switch to the second, switch back -- the fold
2972        // should still be closed.
2973        let path = write_temp_file("activate-fold-roundtrip", "a:\n    x\n    y\n");
2974        let mut a = app_with("first:\n    p\n    q\nsecond:\n    r\n    s\n", 10);
2975        a.set_foldmethod_for_test(FoldMethod::Indent);
2976        a.recompute_folds();
2977        let initial_id = a.editor.document_buffer_id;
2978        // Close the first fold (line 0) on the initial buffer.
2979        let first_idx = a
2980            .editor
2981            .folds
2982            .iter()
2983            .position(|f| f.start_line == 0)
2984            .expect("fold");
2985        a.editor.folds[first_idx].closed = true;
2986        // Open + activate the new buffer.
2987        a.editor
2988            .set_command_line_text(&format!("e {}", path.display()));
2989        a.editor.modal = ModalState::Command;
2990        a.apply(Action::CommandLineSubmit);
2991        // Switch back via :bn.
2992        a.editor.set_command_line_text("bn");
2993        a.editor.modal = ModalState::Command;
2994        a.apply(Action::CommandLineSubmit);
2995        assert_eq!(a.editor.document_buffer_id, initial_id);
2996        // Closed state survived the round-trip.
2997        assert!(
2998            a.editor.folds.iter().any(|f| f.start_line == 0 && f.closed),
2999            "expected fold@0 to remain closed after switch-away-and-back: {:?}",
3000            a.editor.folds
3001        );
3002        let _ = std::fs::remove_file(path);
3003    }
3004
3005    #[test]
3006    fn switching_to_unvisited_buffer_first_time_seeds_folds() {
3007        // Open a second file with foldmethod=manual so its initial
3008        // entry has no folds, switch foldmethod to indent, then
3009        // activate -- the activation hook should seed the folds on
3010        // first visit (entry's `folds` was empty).
3011        let path = write_temp_file("activate-unvisited", "section:\n    a\n    b\n    c\n");
3012        let mut a = app_with("xx", 10);
3013        // Open the second file under foldmethod=manual so no folds
3014        // get seeded into its entry.
3015        a.set_foldmethod_for_test(FoldMethod::Manual);
3016        a.editor
3017            .set_command_line_text(&format!("e {}", path.display()));
3018        a.editor.modal = ModalState::Command;
3019        a.apply(Action::CommandLineSubmit);
3020        let id_target = a.editor.document_buffer_id;
3021        assert!(a.editor.folds.is_empty(), "manual leaves folds empty");
3022        // Switch back to the original buffer.
3023        let original_id = a
3024            .editor
3025            .buffers
3026            .document_ids_sorted()
3027            .into_iter()
3028            .find(|id| *id != id_target)
3029            .expect("original buffer");
3030        a.activate_document(original_id);
3031        // Now flip foldmethod to indent and activate the target;
3032        // the hook should seed folds for the unvisited-under-indent
3033        // buffer on first visit.
3034        a.set_foldmethod_for_test(FoldMethod::Indent);
3035        a.activate_document(id_target);
3036        assert_eq!(a.editor.document_buffer_id, id_target);
3037        assert!(
3038            !a.editor.folds.is_empty(),
3039            "expected activation hook to seed folds on first visit under indent: {:?}",
3040            a.editor.folds
3041        );
3042        let _ = std::fs::remove_file(path);
3043    }
3044
3045    #[test]
3046    fn word_under_cursor_returns_alphanumeric_run() {
3047        let mut a = app_with("hello world", 10);
3048        a.editor.cursor = Position::new(0, 0);
3049        let snap = a.editor.document.snapshot();
3050        assert_eq!(
3051            word_under_cursor(&snap.buffer, a.editor.cursor),
3052            Some("hello".to_string())
3053        );
3054        a.editor.cursor = Position::new(0, 6);
3055        assert_eq!(
3056            word_under_cursor(&snap.buffer, a.editor.cursor),
3057            Some("world".to_string())
3058        );
3059    }
3060
3061    #[test]
3062    fn word_under_cursor_returns_none_off_word() {
3063        let a = app_with("foo bar", 10);
3064        let snap = a.editor.document.snapshot();
3065        // Cursor on the space.
3066        let p = Position::new(0, 3);
3067        assert_eq!(word_under_cursor(&snap.buffer, p), None);
3068    }
3069
3070    #[test]
3071    fn ctrl_o_walks_back_to_document_from_help() {
3072        // `<C-o>` from inside a help buffer should land back on the
3073        // document spot the user opened the help from. That's the
3074        // first user-visible win of active-buffer routing.
3075        let mut a = app_with("first\nsecond\nthird\nfourth", 10);
3076        a.editor.cursor = Position::new(2, 0);
3077        // Open help via the same path the App uses internally so
3078        // the position-history entry is recorded.
3079        a.open_popup(
3080            HelpContent::from_lines("h", vec!["help body".into()]),
3081            crate::popup::PopupPlacement::Centered,
3082        );
3083        assert_eq!(a.editor.active_buffer, BufferKind::Help);
3084        a.apply(Action::JumpHistoryBack);
3085        assert_eq!(a.editor.active_buffer, BufferKind::Document);
3086        assert_eq!(a.editor.cursor.line, 2);
3087    }
3088
3089    #[test]
3090    fn apply_edit_blocking_records_lsp_edit_when_attached() {
3091        let mut app = app_with("abc\n", 5);
3092        // Attach a fake URI mapping so lsp_record_edit
3093        // reaches the supervisor.
3094        use std::str::FromStr;
3095        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
3096        app.editor
3097            .buffer_uris
3098            .insert(app.editor.document_buffer_id, uri.clone());
3099        // Test-only: register the URI directly with the
3100        // supervisor under a mock actor. Without a real
3101        // ServerHandle attach_handle requires one, so instead
3102        // we verify the wiring fires by checking that the
3103        // record-edit path doesn't panic and the buffer_uri
3104        // mapping survives.
3105        let edit = Edit::insert(Position::new(0, 0), "x");
3106        let _ = app.apply_edit_blocking(edit.clone());
3107        // Buffer mapping unchanged; record_edit is best-effort
3108        // (skips if no actor attached for the URI).
3109        assert_eq!(
3110            app.editor.buffer_uris.get(&app.editor.document_buffer_id),
3111            Some(&uri)
3112        );
3113    }
3114
3115    #[test]
3116    fn apply_edit_blocking_with_no_lsp_attachment_is_safe() {
3117        // Without a buffer_uri mapping, lsp_record_edit
3118        // short-circuits. No panic, no crash, edit still
3119        // commits.
3120        let mut app = app_with("hi\n", 5);
3121        let r = app.apply_edit_blocking(Edit::insert(Position::new(0, 0), "x"));
3122        assert!(r.is_ok());
3123    }
3124
3125    #[test]
3126    fn apply_edit_batch_blocking_records_each_edit_in_order() {
3127        let mut app = app_with("abc\n", 5);
3128        let edits = vec![
3129            Edit::insert(Position::new(0, 0), "1"),
3130            Edit::insert(Position::new(0, 1), "2"),
3131        ];
3132        // No LSP attachment seeded -> records short-circuit;
3133        // we only check the path is reachable (no panic).
3134        let r = app.apply_edit_batch_blocking(edits);
3135        assert!(r.is_ok());
3136    }
3137
3138    #[test]
3139    fn apply_per_language_toml_overrides_merges_with_spec_defaults() {
3140        // User flips markdown's `auto_trigger = true`; the
3141        // spec default `sources` (no LSP) should still apply.
3142        let ws = fresh_workspace("merge-with-defaults");
3143        write_workspace_config(
3144            &ws,
3145            "[completion.per-language.markdown]\n\
3146             auto_trigger = true\n",
3147        );
3148        let mut a = app_with("", 5);
3149        a.load_persistent_config(Some(&ws));
3150        a.apply_per_language_toml_overrides();
3151        let eff = a.effective_completion_for("markdown");
3152        assert!(eff.auto_trigger, "TOML wins for auto_trigger");
3153        let lsp_id =
3154            lattice_completion::SourceId::new(lattice_completion::LSP_COMPLETION_SOURCE_ID);
3155        assert!(
3156            !eff.source_enabled(&lsp_id),
3157            "default `sources` (no LSP) preserved when TOML didn't set it",
3158        );
3159    }
3160
3161    #[test]
3162    fn apply_per_language_toml_overrides_seeds_new_language() {
3163        // `python` isn't in the spec defaults; a TOML entry
3164        // creates the slot.
3165        let ws = fresh_workspace("new-language");
3166        write_workspace_config(
3167            &ws,
3168            "[completion.per-language.python]\n\
3169             sources = [\"lsp\"]\n\
3170             auto_insert_single = true\n",
3171        );
3172        let mut a = app_with("", 5);
3173        a.load_persistent_config(Some(&ws));
3174        a.apply_per_language_toml_overrides();
3175        let eff = a.effective_completion_for("python");
3176        let lsp_id =
3177            lattice_completion::SourceId::new(lattice_completion::LSP_COMPLETION_SOURCE_ID);
3178        assert!(eff.source_enabled(&lsp_id));
3179        let buffer_words_id =
3180            lattice_completion::SourceId::new(lattice_completion::BufferWordsSource::ID);
3181        assert!(
3182            !eff.source_enabled(&buffer_words_id),
3183            "`sources = [\"lsp\"]` excludes buffer-words",
3184        );
3185        assert!(eff.auto_insert_single);
3186    }
3187
3188    #[test]
3189    fn apply_per_language_toml_overrides_warns_on_unknown_key() {
3190        let ws = fresh_workspace("unknown-perlang-key");
3191        write_workspace_config(
3192            &ws,
3193            "[completion.per-language.markdown]\n\
3194             bogus_field = 5\n",
3195        );
3196        let mut a = app_with("", 5);
3197        a.load_persistent_config(Some(&ws));
3198        // Loader echo handles structural sections silently
3199        // until `apply_per_language_toml_overrides` runs.
3200        let pre = a.editor.last_message.clone();
3201        a.apply_per_language_toml_overrides();
3202        let msg = a.editor.last_message.as_ref().expect("warning echoed");
3203        assert_ne!(Some(msg.clone()), pre, "new echo posted");
3204        assert_eq!(msg.level, EchoLevel::Warn);
3205        assert!(msg.text.contains("bogus_field"), "got `{}`", msg.text);
3206    }
3207
3208    #[tokio::test]
3209    async fn mode_on_activate_runs_and_returns_guard() {
3210        // M-async.2: validation succeeds synchronously; the
3211        // lifecycle future is spawned. Yield to the runtime so
3212        // the spawned task runs and stashes the Guard.
3213        let a = app_with("hi", 5);
3214        let test_mode = TestLocalsMode::new();
3215        let counter = test_mode.counter.clone();
3216        let mode_id = {
3217            let mut registry = (**a.editor.mode_registry.load()).clone();
3218            let id = registry.register(test_mode).expect("register");
3219            a.editor.mode_registry.store(std::sync::Arc::new(registry));
3220            id
3221        };
3222
3223        let mut active = lattice_mode::ActiveModes::new();
3224        let guards = lattice_mode::GuardStoreHandle::new();
3225        a.editor
3226            .mode_registry
3227            .load_full()
3228            .activate_minor(
3229                &mut active,
3230                &guards,
3231                &a.editor.config,
3232                &a.editor.event_bus,
3233                &a.editor.services,
3234                lattice_protocol::ids::BufferId::new(0),
3235                mode_id,
3236                lattice_mode::CapabilitySet::empty(),
3237            )
3238            .expect("activate");
3239
3240        // The lifecycle task's `on_activate` body fires the side
3241        // effect synchronously (no `.await`) before constructing
3242        // the Guard; tokio still needs a yield to pick up the
3243        // spawned task.
3244        for _ in 0..10 {
3245            if guards.contains(lattice_protocol::ids::BufferId::new(0), mode_id) {
3246                break;
3247            }
3248            tokio::task::yield_now().await;
3249        }
3250        assert_eq!(
3251            counter.load(std::sync::atomic::Ordering::SeqCst),
3252            42,
3253            "on_activate side effect should have fired"
3254        );
3255        assert!(
3256            guards.contains(lattice_protocol::ids::BufferId::new(0), mode_id),
3257            "Guard should be stashed in GuardStore"
3258        );
3259    }
3260
3261    #[tokio::test]
3262    async fn mode_deactivate_drops_guard_and_fires_cleanup() {
3263        // M-async.2: activate spawns; yield to let the Guard land,
3264        // then deactivate synchronously and observe Drop fired.
3265        let a = app_with("hi", 5);
3266        let test_mode = TestLocalsMode::new();
3267        let counter = test_mode.counter.clone();
3268        let mode_id = {
3269            let mut registry = (**a.editor.mode_registry.load()).clone();
3270            let id = registry.register(test_mode).expect("register");
3271            a.editor.mode_registry.store(std::sync::Arc::new(registry));
3272            id
3273        };
3274
3275        let mut active = lattice_mode::ActiveModes::new();
3276        let guards = lattice_mode::GuardStoreHandle::new();
3277        a.editor
3278            .mode_registry
3279            .load_full()
3280            .activate_minor(
3281                &mut active,
3282                &guards,
3283                &a.editor.config,
3284                &a.editor.event_bus,
3285                &a.editor.services,
3286                lattice_protocol::ids::BufferId::new(0),
3287                mode_id,
3288                lattice_mode::CapabilitySet::empty(),
3289            )
3290            .expect("activate");
3291        for _ in 0..10 {
3292            if guards.contains(lattice_protocol::ids::BufferId::new(0), mode_id) {
3293                break;
3294            }
3295            tokio::task::yield_now().await;
3296        }
3297        assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 42);
3298
3299        a.editor
3300            .mode_registry
3301            .load_full()
3302            .deactivate_minor(
3303                &mut active,
3304                &guards,
3305                &a.editor.event_bus,
3306                lattice_protocol::ids::BufferId::new(0),
3307                mode_id,
3308            )
3309            .expect("deactivate");
3310        assert_eq!(
3311            counter.load(std::sync::atomic::Ordering::SeqCst),
3312            0,
3313            "Guard's Drop impl should have reset the counter"
3314        );
3315        assert!(
3316            !guards.contains(lattice_protocol::ids::BufferId::new(0), mode_id),
3317            "Guard should be removed from GuardStore"
3318        );
3319    }
3320}