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, ®) {
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}