Skip to main content

lattice_ui_tui/
runtime.rs

1//! Terminal IO loop. Sets up raw mode + alt screen, draws frames, polls
2//! events, restores terminal state on exit.
3//!
4//! This is the only file in the crate that talks to the terminal directly.
5//! Everything else is pure and unit-tested.
6
7use std::io::Stdout;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::mpsc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13use anyhow::{Context, Result};
14use crossterm::cursor::SetCursorStyle;
15use crossterm::event::{
16    self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
17    Event, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
18};
19use crossterm::execute;
20use crossterm::terminal::{
21    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
22    supports_keyboard_enhancement,
23};
24use lattice_grammar::ModalState;
25use ratatui::Terminal;
26use ratatui::backend::CrosstermBackend;
27
28use lattice_core::Document;
29
30use crate::app::{Action, App};
31use crate::input::{TranslateContext, translate};
32use crate::render::draw_frame;
33
34/// Perf instrumentation (paired with `LATTICE_PERF_INPUT`): counts bytes
35/// written to the terminal so the input timer can report **per-frame write
36/// volume**. This is the real driver of terminal present time on a slow pty:
37/// `terminal.draw()` returns as soon as the diff lands in the pty buffer, but
38/// the terminal emulator then spends time parsing + presenting those bytes —
39/// time our `input→glyph` timer can't see. vim writes a few bytes per
40/// keystroke; if we write kilobytes (a whole-viewport rewrite) the same
41/// terminal that renders vim instantly will visibly lag on us. Reset before
42/// each `draw()`, read after.
43static PERF_FRAME_BYTES: AtomicU64 = AtomicU64::new(0);
44
45/// Wraps the terminal writer and tallies bytes into [`PERF_FRAME_BYTES`].
46/// Forwards everything to the inner writer; the relaxed atomic add is
47/// negligible. Always wired (so the byte count is available whenever
48/// `LATTICE_PERF_INPUT` is set) — the only cost when the env is unset is the
49/// add itself, which is in the noise next to the syscall it accompanies.
50struct CountingWriter<W> {
51    inner: W,
52}
53
54impl<W: std::io::Write> std::io::Write for CountingWriter<W> {
55    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
56        let n = self.inner.write(buf)?;
57        PERF_FRAME_BYTES.fetch_add(n as u64, Ordering::Relaxed);
58        Ok(n)
59    }
60
61    fn flush(&mut self) -> std::io::Result<()> {
62        self.inner.flush()
63    }
64}
65
66/// The concrete terminal backend type, now wrapped in the byte counter.
67type TermBackend = CrosstermBackend<CountingWriter<Stdout>>;
68
69/// The chord table a modal state resolves against — the same mapping the host
70/// uses. Needed by the AP.0.2 fall-through to ask the trie which layer bound a
71/// declined chord.
72pub fn run(document: Document, startup_lesson: Option<u32>) -> Result<()> {
73    let mut terminal = setup().context("setup terminal")?;
74    let mut app = App::new(document);
75    // Load persistent config (TOML) before LSP attach work so
76    // any overrides that affect LSP behaviour land before the
77    // attach driver processes the initial document's
78    // `Event::DocumentOpened`. Workspace root is the CWD
79    // walked up to the first `.git` / `.lattice/` marker (or
80    // the CWD itself if neither is found). Failures are
81    // surfaced via the App's echo, never abort startup.
82    // Phase 5.8.AA.u: workspace-root discovery + persistent-config
83    // loading both live on the host so GPUI gets the same boot
84    // behaviour. The TUI runtime is now a thin wrapper.
85    let workspace_root = lattice_core::project::root_from_cwd();
86    app.load_persistent_config(workspace_root.as_deref());
87    app.apply_per_language_toml_overrides();
88    // OS.1: only now is `ui.keyboard_enhancement` real -- `setup()` ran before
89    // the TOML was read, so asking there would always answer the default.
90    let keyboard_enhanced = push_keyboard_enhancement(
91        app.options()
92            .config
93            .get_typed::<lattice_host::ui::theme_options::UiKeyboardEnhancement>()
94            .map(|v| *v)
95            .unwrap_or(true),
96    );
97    // built-ins 2026-06-13: load embedded + user snippet packs once
98    // at startup (after config so `snippet_dirs` overrides apply).
99    // Quiet — logs, no echo.
100    app.load_snippets_at_startup();
101    // T.5: `--tutor [N]` opens the tutor buffer before the first draw.
102    if let Some(n) = startup_lesson {
103        app.open_tutor(n);
104    }
105    // LSP attach is event-driven: `App::new` already
106    // published `Event::DocumentOpened` for the initial
107    // document (if path-bearing). The attach driver wired in
108    // `build_lsp_subsystem` runs on the LSP runtime and
109    // submits the open to the supervisor *off* the UI thread.
110    // The first frame can draw immediately without waiting on
111    // the LSP `initialize` round-trip -- paramount goal #4
112    // (asynchronicity).
113    let result = main_loop(&mut terminal, app);
114    teardown(&mut terminal, keyboard_enhanced).context("teardown terminal")?;
115    result
116}
117
118// Phase 5.8.AA.u hoisted this out of the TUI runtime onto the host;
119// PR.2 moved it again, to `lattice_core::project::root_from_cwd` —
120// so both renderers and every other consumer share one rule about
121// where a project begins.
122
123// Phase 5.5.LSP.1: the shared LSP runtime + spawn helper now
124// live in `lattice_runtime::runtime` so host-side dispatchers
125// can fire LSP requests without taking a back-edge through this
126// (renderer-specific) crate. Re-exported here so the existing
127// `crate::runtime::*` call sites (34 inside `App`) keep
128// compiling unchanged. Both names point at the single
129// `lattice_runtime::LSP_RUNTIME` OnceLock -- no behaviour change.
130// Phase 5.8.AD.2: `lsp_runtime` re-export retired -- last
131// caller (`do_lsp_restart`) migrated host-side. `spawn_on_lsp_runtime`
132// is still publicly re-exported for the few App-resident async
133// helpers that haven't migrated yet.
134pub use lattice_runtime::runtime::spawn_on_lsp_runtime;
135
136fn setup() -> Result<Terminal<TermBackend>> {
137    enable_raw_mode().context("enable raw mode")?;
138    let mut stdout = std::io::stdout();
139    execute!(stdout, EnterAlternateScreen).context("enter alt screen")?;
140    // Bracketed paste tells the terminal to wrap clipboard pastes in
141    // ESC[200~ ... ESC[201~ markers. Crossterm decodes those as
142    // `Event::Paste(String)`, which we hand to the App as a single
143    // bracketed-paste burst (one undo unit). Without this, terminals
144    // that bind Ctrl+V to clipboard paste (Konsole, Windows Terminal,
145    // tmux configs) replay the clipboard contents as a stream of
146    // raw key events -- which Normal mode then interprets as commands
147    // and the user gets unexpected behaviour instead of a paste.
148    execute!(stdout, EnableBracketedPaste).context("enable bracketed paste")?;
149    let backend = CrosstermBackend::new(CountingWriter { inner: stdout });
150    Terminal::new(backend).context("create terminal")
151}
152
153/// OS.1: should the keyboard-enhancement protocol be pushed?
154///
155/// Both conditions, extracted so the decision is testable without a tty —
156/// which is the only part of this slice a test can reach.
157fn should_push_enhancement(option: bool, supported: bool) -> bool {
158    option && supported
159}
160
161/// OS.1: ask the terminal to disambiguate `<S-CR>` / `<C-CR>` / `<M-S-CR>`
162/// from a bare `<CR>`.
163///
164/// Without this a terminal sends the same `\r` for all four, so those chords
165/// are unreachable however they are bound. `lattice-protocol` has always
166/// spelled them and the GPUI peer has always delivered them; this closes the
167/// renderer asymmetry rather than adding a capability.
168///
169/// `DISAMBIGUATE_ESCAPE_CODES` only. `REPORT_ALL_KEYS_AS_ESCAPE_CODES` would
170/// route ordinary text input through the escape path, which is not wanted.
171///
172/// **Called after the persistent config loads, not from [`setup`].** `setup`
173/// runs before `load_persistent_config`, so an option read there would always
174/// see the compiled-in default and `:set ui.keyboard_enhancement=off` in a
175/// user's TOML would be silently ignored. Nothing reads a key between the two
176/// points, so pushing here costs nothing.
177///
178/// Returns whether the push actually happened, so teardown pops exactly what
179/// was pushed. A terminal that advertises support and then refuses must not
180/// fail startup: log and carry on unenhanced.
181fn push_keyboard_enhancement(option: bool) -> bool {
182    if !should_push_enhancement(option, supports_keyboard_enhancement().unwrap_or(false)) {
183        return false;
184    }
185    let mut stdout = std::io::stdout();
186    match execute!(
187        stdout,
188        PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
189    ) {
190        Ok(()) => true,
191        Err(e) => {
192            tracing::debug!(error = %e, "terminal refused the keyboard-enhancement push");
193            false
194        }
195    }
196}
197
198/// MO.1: turn terminal mouse reporting on or off, following `ui.mouse`.
199///
200/// Kept out of [`setup`] because it is the one terminal capability the
201/// editor asks for that **takes something away**: while capture is on,
202/// the terminal emulator stops seeing the mouse, so click-drag text
203/// selection and middle-click paste no longer work (some terminals
204/// offer Shift-drag as an escape hatch; not all do). That is why the
205/// option defaults off and why this is toggleable at runtime rather
206/// than decided once at startup — `:set ui.mouse` takes effect on the
207/// next loop iteration, and the user can turn it back off the moment
208/// they want to copy something.
209///
210/// Failures are logged and swallowed: a terminal that rejects the
211/// escape sequence should leave the editor running without mouse
212/// support, not refuse to start.
213fn set_mouse_capture(on: bool) {
214    let mut stdout = std::io::stdout();
215    let result = if on {
216        execute!(stdout, EnableMouseCapture)
217    } else {
218        execute!(stdout, DisableMouseCapture)
219    };
220    if let Err(e) = result {
221        tracing::debug!(error = %e, enable = on, "terminal rejected mouse-capture toggle");
222    }
223}
224
225fn teardown(terminal: &mut Terminal<TermBackend>, keyboard_enhanced: bool) -> Result<()> {
226    // Unconditional, and harmless when capture was never enabled: the
227    // sequence is a no-op for a terminal that is not reporting. Leaving
228    // a terminal in reporting mode after exit is the failure worth
229    // avoiding — the user's shell would receive mouse escape codes as
230    // garbage input.
231    execute!(terminal.backend_mut(), DisableMouseCapture)
232        .context("disable mouse capture")
233        .unwrap_or_else(|e| tracing::debug!(error = %e, "disable mouse capture"));
234    // Restore the user's default cursor shape before tearing down the
235    // alt screen -- otherwise the shell prompt inherits whatever the
236    // editor was rendering.
237    execute!(terminal.backend_mut(), SetCursorStyle::DefaultUserShape)
238        .context("restore cursor style")?;
239    // OS.1: pop exactly what was pushed, and before raw mode goes away. A
240    // terminal left in enhancement mode hands the user's shell disambiguated
241    // key encodings, which is a worse failure than the one the push fixes --
242    // so this is best-effort-logged rather than `?`, like the mouse restore
243    // above: one refused sequence must not skip the restores below it.
244    if keyboard_enhanced {
245        execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags)
246            .unwrap_or_else(|e| tracing::debug!(error = %e, "pop keyboard enhancement"));
247    }
248    execute!(terminal.backend_mut(), DisableBracketedPaste).context("disable bracketed paste")?;
249    disable_raw_mode().context("disable raw mode")?;
250    execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alt screen")?;
251    terminal.show_cursor().context("show cursor")?;
252    Ok(())
253}
254
255/// Map the App's modal state to a terminal cursor shape via the
256/// renderer-neutral `host::cursor_shape::CursorShape`. The vim
257/// convention (Block for command-language modes, Bar for Insert /
258/// Command-line, Underscore for Replace) lives once on the host
259/// (5.8.N); this peer just maps to crossterm's primitive.
260///
261/// Terminal-mode T2.b (2026-05-25): when the active buffer is a
262/// Terminal AND `terminal-insert-mode` is active, the shape
263/// flips to `SteadyBar` even though `ModalState` stays `Normal`
264/// (TerminalInsert is a minor mode, not a separate modal
265/// variant). The Normal-in-terminal state keeps `SteadyBlock`.
266fn cursor_style_for(modal: ModalState, terminal_insert_active: bool) -> SetCursorStyle {
267    use lattice_host::cursor_shape::CursorShape;
268    if terminal_insert_active {
269        return SetCursorStyle::SteadyBar;
270    }
271    match CursorShape::for_mode(modal) {
272        CursorShape::Block => SetCursorStyle::SteadyBlock,
273        CursorShape::Bar => SetCursorStyle::SteadyBar,
274        CursorShape::Underline => SetCursorStyle::SteadyUnderScore,
275    }
276}
277
278/// I.3 (event-driven wake): a reason the main loop woke. The loop blocks on a
279/// single channel of these instead of polling the terminal on a 100ms timer.
280/// `Input` carries a decoded terminal event from the dedicated reader thread;
281/// `Repaint` is forwarded from the actor's `paint_request` `Notify` (async
282/// syntax / LSP / worker republishes) so a background republish reaches the
283/// screen promptly without the loop spinning. See
284/// `docs/dev/operations/slice-plans/input-latency.md` I.3.
285enum Wake {
286    Input(Event),
287    Repaint,
288}
289
290/// Apply one decoded terminal event to the app. Mirrors the per-event arm the
291/// pre-I.3 inline drain ran: keys translate through the published `translator`
292/// substate (no `&Editor` borrow); a bracketed paste is one edit; a resize
293/// defers to the next iteration's viewport setup. Sets `last_input_at` only for
294/// events that produced fresh input, so the `LATTICE_PERF_INPUT` timer
295/// attributes the next draw to a keystroke and not to an async repaint.
296fn apply_event(app: &mut App, ev: Event, perf_input: bool, last_input_at: &mut Option<Instant>) {
297    match ev {
298        Event::Key(k) => {
299            // Esc dismisses an open floating popup (hover / signature /
300            // `:describe-*`) even when the DOCUMENT keeps focus (State A) —
301            // mirrors the GPUI peer's `on_key_down` gate (window.rs). Without
302            // this, Esc only closed the popup once focus had moved into it
303            // (State B) or on cursor motion (HoverMode auto-dismiss); plain
304            // `<Esc>` over the document did nothing. Gated to State A
305            // (`buffer_kind != Help`) so a FOCUSED popup (State B) keeps the
306            // normal Esc path, where in-popup sub-modes (search, …) can
307            // cancel first before the popup closes.
308            if matches!(k.code, crossterm::event::KeyCode::Esc)
309                && app.popup().is_open()
310                && !app.ad().popup_focused
311            {
312                app.dismiss_popup();
313                if perf_input {
314                    *last_input_at = Some(Instant::now());
315                }
316                return;
317            }
318            // Slice 3c.final.B (group 5): translator inputs read through
319            // `rs.translator` instead of `&app.editor.{builtins,keymap,
320            // partial_chord}`. The Arc-bound substate keeps the borrows valid
321            // for the translate call without tying them to `Editor`'s lifetime.
322            let ad = app.ad();
323            let translator = app.render_state.load().translator.clone();
324            let ctx = TranslateContext {
325                modal: ad.modal,
326                builtins: &translator.builtins,
327                pending_count: ad.pending_count,
328                op_count: ad.op_count,
329                recording_macro: ad.macro_recording,
330                active_buffer: ad.buffer_kind,
331                completion_open: ad.completion_open,
332                chord_capture: app.chord_capture_active(),
333                picker_open: ad.picker_open,
334                insert_completion_open: app.completion_popup_active(),
335                snippet_active: ad.snippet_active,
336                terminal_insert_active: ad.terminal_insert_active,
337                terminal_esc_exits: ad.terminal_esc_exits,
338                terminal_app_cursor_keys: ad.terminal_app_cursor_keys,
339                terminal_insert_exit_pending: ad.terminal_insert_exit_pending,
340                terminal_visual_active: ad.terminal_visual_active,
341                keymap: &translator.keymap,
342                partial_chord: &translator.partial_chord,
343                active_minor_modes: &translator.active_minor_modes,
344            };
345            // AP.0.2: the inputs the fall-through has to reuse, captured
346            // BEFORE `apply` mutates the partial-chord state. Re-resolving with
347            // an empty prefix resolves the trailing key alone, which is how a
348            // declined `<leader>oJ` ran vim's `J` and joined two lines.
349            let prefix_before: Vec<lattice_protocol::KeyChord> = translator.partial_chord.to_vec();
350            let layers_before: Vec<lattice_mode::ModeId> = translator.active_minor_modes.to_vec();
351            let action = translate(ctx, k);
352            if app.apply(action) {
353                // AP.0.2 fall-through (TUI live path): the resolved binding
354                // was a plugin grammar action that DECLINED — it did nothing,
355                // so re-resolve with the DECLINING LAYER REMOVED, peeling one
356                // layer per decline until something handles the key or only the
357                // always-on Builtin / User layers are left.
358                //
359                // Mirrors the host `dispatch_chord` peel exactly (GPUI rides
360                // that path). Dropping every mode layer at once — which is what
361                // this did — means a second declining layer never sees the
362                // chord, so org-table-mode's `<Tab>` skipped org-mode's fold
363                // cycle and hit the builtin. The TUI's translate→apply path
364                // lost the chord by dispatch time, so the re-translate happens
365                // here where `k` is still in hand.
366                // `k` is still the crossterm event; the trie speaks `KeyChord`.
367                let Some(k_chord) = crate::chord::from_event(&k) else {
368                    return;
369                };
370                // The walk is `lattice_host::decline::DeclinePeel`'s, not this
371                // loop's. It was this loop's, and the host dispatcher had a
372                // second copy, and the GPUI peer had none — which is how every
373                // key auto-pair binds came to be dead in that renderer while
374                // this one was fine.
375                let mut peel = lattice_host::decline::DeclinePeel::new(
376                    app.ad().modal,
377                    prefix_before.clone(),
378                    k_chord,
379                    layers_before.clone(),
380                );
381                loop {
382                    let ad = app.ad();
383                    let translator = app.render_state.load().translator.clone();
384                    // `None` ⇒ the winner came from Builtin / User, which
385                    // cannot decline: nothing left to peel.
386                    let Some(layers) = peel
387                        .peel(&translator.keymap)
388                        .map(<[lattice_mode::ModeId]>::to_vec)
389                    else {
390                        break;
391                    };
392                    let ctx = TranslateContext {
393                        modal: ad.modal,
394                        builtins: &translator.builtins,
395                        pending_count: ad.pending_count,
396                        op_count: ad.op_count,
397                        recording_macro: ad.macro_recording,
398                        active_buffer: ad.buffer_kind,
399                        completion_open: ad.completion_open,
400                        chord_capture: app.chord_capture_active(),
401                        picker_open: ad.picker_open,
402                        insert_completion_open: app.completion_popup_active(),
403                        snippet_active: ad.snippet_active,
404                        terminal_insert_active: ad.terminal_insert_active,
405                        terminal_esc_exits: ad.terminal_esc_exits,
406                        terminal_app_cursor_keys: ad.terminal_app_cursor_keys,
407                        terminal_insert_exit_pending: ad.terminal_insert_exit_pending,
408                        terminal_visual_active: ad.terminal_visual_active,
409                        keymap: &translator.keymap,
410                        partial_chord: &prefix_before,
411                        active_minor_modes: &layers,
412                    };
413                    let fallthrough = translate(ctx, k);
414                    let declined_again = app.apply(fallthrough);
415                    if !declined_again || peel.exhausted() {
416                        break;
417                    }
418                }
419            }
420            if perf_input {
421                *last_input_at = Some(Instant::now());
422            }
423        }
424        Event::Paste(text) => {
425            // Real bracketed-paste burst from the terminal's clipboard
426            // shortcut. Hand the payload to the app as a single edit; Ctrl+V
427            // keystrokes (the binding for blockwise visual) still arrive as
428            // `Event::Key` because they're not the terminal's paste path.
429            app.apply(Action::PasteText(text));
430            if perf_input {
431                *last_input_at = Some(Instant::now());
432            }
433        }
434        Event::Resize(_, _) => {
435            // next iteration's top-of-loop setup reads the new size
436        }
437        // ML.4: a left-click on a modeline element dispatches the
438        // element's declared command. Mouse events only arrive at all
439        // when `ui.mouse` is on (MO.1), so no gate is needed here.
440        //
441        // The host is a router and nothing more: the handler body lives
442        // in the mode or plugin that registered the element, reached
443        // through the same `CommandInvocation` path a keystroke takes
444        // (modeline.md §6, §9). No per-element handler, and no new
445        // `Action` variant.
446        Event::Mouse(m) => {
447            use crossterm::event::{MouseButton, MouseEventKind};
448            // The modeline claims a left-press before the body sees it:
449            // its zones sit on the pane's status row, which is outside
450            // every `PaneHitZone` (those cover `content_rect` only), so
451            // the two maps cannot both answer — but asking the modeline
452            // first keeps that a property of the code and not only of
453            // the geometry.
454            let modeline_hit = if matches!(m.kind, MouseEventKind::Down(MouseButton::Left)) {
455                // Bound out of the `if` so the `RefCell` borrow ends
456                // before `app.apply` takes `&mut app`.
457                app.modeline_hits.borrow().hit(m.column, m.row)
458            } else {
459                None
460            };
461            if let Some(command) = modeline_hit {
462                app.apply(Action::Invoke(lattice_grammar::CommandInvocation::of(
463                    command,
464                )));
465                if perf_input {
466                    *last_input_at = Some(Instant::now());
467                }
468                return;
469            }
470            // MO.2: the editor body. `resolve` answers `None` for a cell
471            // no pane painted (the `:` line, a status row, the gap
472            // between splits), which is how those stay inert.
473            let resolved = app.pane_hits.borrow().resolve(m.column, m.row);
474            let Some(hit) = resolved else {
475                return;
476            };
477            let action = match m.kind {
478                // The wheel scrolls the pane under the POINTER and does
479                // not focus it — vim's `mousescroll`, Zed and Helix all
480                // agree, and it is what makes a wheel over a reference
481                // split usable mid-edit.
482                MouseEventKind::ScrollDown => Some(Action::MouseScroll {
483                    pane: hit.zone.pane_id,
484                    down: true,
485                }),
486                MouseEventKind::ScrollUp => Some(Action::MouseScroll {
487                    pane: hit.zone.pane_id,
488                    down: false,
489                }),
490                // A press positions the caret; dragging with the button
491                // held extends from where the press landed. `Drag` is
492                // reported only while a button is down, so the two share
493                // one resolver and differ by `extend` alone.
494                MouseEventKind::Down(MouseButton::Left) => mouse_goto(app, &hit, false),
495                MouseEventKind::Drag(MouseButton::Left) => mouse_goto(app, &hit, true),
496                _ => None,
497            };
498            if let Some(action) = action {
499                app.apply(action);
500                if perf_input {
501                    *last_input_at = Some(Instant::now());
502                }
503            }
504        }
505        _ => {}
506    }
507}
508
509/// MO.2: turn a resolved cell into a buffer position.
510///
511/// The inverse of the compose loop, and derived from it rather than
512/// re-implemented: the row's origin was **recorded while painting**
513/// (`PaneHitMap::set_rows`), and the column goes back through
514/// `lattice_cells::display_col_to_source_byte`, the binary-search
515/// inverse of the very map the caret is drawn with. That is what makes
516/// a click land exactly where the caret would be — including under soft
517/// wrap, inlay hints and conceal, none of which this function knows
518/// anything about individually.
519///
520/// `None` when the cell is on the gutter or on a row mirroring no source
521/// line, which is how those stay inert rather than resolving to
522/// column 0 of something.
523fn mouse_goto(app: &App, hit: &lattice_host::mouse::BodyHit, extend: bool) -> Option<Action> {
524    let body_width = hit.zone.width.saturating_sub(hit.zone.text_left).max(1) as u32;
525    let logical_col = hit.logical_col(body_width)?;
526    let origin = hit.origin?;
527
528    let handle = app.buffers().registry.document_handle(hit.zone.buffer_id)?;
529    let snapshot = handle.snapshot();
530    let line_text = snapshot.buffer.line(origin.source_line).unwrap_or_default();
531
532    // The display row carries the tables the forward map used: inlay
533    // splices and conceal ranges for THIS line. Absent (matrix not built
534    // yet for a just-opened buffer) means no splices, which is the
535    // identity — a click still lands, just without corrections nothing
536    // has applied yet either.
537    let matrix = app
538        .render_state
539        .load()
540        .cells
541        .load_full()
542        .display_matrix_for_pane(hit.zone.pane_id)
543        .map(|cell| cell.load_full());
544    let row = matrix
545        .as_ref()
546        .and_then(|m| m.row_at_source_line(origin.source_line));
547
548    // Char columns, not bytes — the space the cell substrate's column
549    // tables live in (see the byte-vs-char note on
550    // `CellRow::byte_to_combined_col`). The conversion back to a byte
551    // offset is this function's job, and it is why `max_source_byte` is
552    // a char count.
553    let char_len = line_text.chars().count() as u32;
554    let char_col = match row {
555        Some(r) => lattice_cells::display_col_to_source_byte(
556            logical_col,
557            char_len,
558            &r.col_map,
559            &r.conceals,
560        ),
561        None => logical_col.min(char_len),
562    };
563    let byte = line_text
564        .char_indices()
565        .nth(char_col as usize)
566        .map(|(b, _)| b as u32)
567        .unwrap_or(line_text.len() as u32);
568
569    Some(Action::MouseGoto {
570        pane: hit.zone.pane_id,
571        line: origin.source_line,
572        byte,
573        extend,
574    })
575}
576
577/// Drain a batch of wakes: the `first` one that unblocked the loop plus every
578/// other wake already buffered (zero-wait `try_recv`). Input events apply and
579/// coalesce — a typing burst of N queued keys becomes N applies + ONE draw (the
580/// keystroke UX contract: the displayed text never trails the keystrokes).
581/// Stops the instant a quit lands so later buffered keys aren't applied against
582/// a tearing-down app. `Repaint` wakes apply nothing — they exist purely to
583/// trigger the single redraw at the loop top so an async republish (syntax
584/// recolour, LSP decoration) reaches the screen.
585///
586/// Returns `true` when the batch contained at least one `Repaint` and NO input
587/// events. The caller uses this to call `drain_async_pending` so idle LSP
588/// arrivals (hover, signature-help) reach the screen on the repaint itself
589/// rather than waiting for the next keystroke (X1b).
590fn drain_wakes(
591    app: &mut App,
592    rx: &mpsc::Receiver<Wake>,
593    first: Wake,
594    perf_input: bool,
595    last_input_at: &mut Option<Instant>,
596) -> bool {
597    let mut had_input = false;
598    let mut next = Some(first);
599    while let Some(wake) = next.take() {
600        if let Wake::Input(ev) = wake {
601            had_input = true;
602            apply_event(app, ev, perf_input, last_input_at);
603        }
604        if app.render_state.load().lifecycle.should_quit {
605            return false;
606        }
607        next = rx.try_recv().ok();
608    }
609    !had_input
610}
611
612fn main_loop(terminal: &mut Terminal<TermBackend>, mut app: App) -> Result<()> {
613    // Terminal-mode T2.b (2026-05-25): cursor-style cache now keys
614    // off `(modal, terminal_insert_active)` since TerminalInsert is
615    // a minor mode that doesn't flip `ModalState`. Without the
616    // minor-mode bit in the cache key, entering / leaving
617    // Terminal-Insert wouldn't re-push the cursor style and the
618    // user would see a stale block where a bar belongs.
619    let mut last_cursor_inputs: Option<(ModalState, bool)> = None;
620    // Diff cache for the per-frame terminal-width dispatch (see loop body):
621    // the width only changes on a resize, so we dispatch only on change
622    // rather than every frame.
623    let mut last_terminal_width: Option<u16> = None;
624    // Diff cache for the per-frame viewport-height push. `set_viewport_height`
625    // was dispatched UNCONDITIONALLY every iteration → a publish (+ both worker
626    // wakes) per loop tick even when nothing changed — the idle/per-keystroke
627    // publish storm. Push only when the resolved height actually changes.
628    let mut last_viewport_height: Option<u32> = None;
629    // PU.1b-3: diff cache for the floating-popup inner-geometry hand-off
630    // (mirror of `last_viewport_height` for the synthetic popup pane).
631    // `None` when no floating popup is open; `Some((rows, cols))` carries
632    // the popup's inner rect. Push only on change so a steady-state popup
633    // costs zero actor RPCs per frame.
634    let mut last_popup_dims: Option<(u32, u32)> = None;
635    let mut last_band_dims: Option<(u32, u32)> = None;
636    // PU.5c: diff-then-send cache for the completion-docs popup geometry.
637    let mut last_completion_docs_dims: Option<(u32, u32)> = None;
638    // Opt-in keystroke→glyph timer (set env `LATTICE_PERF_INPUT=1`). Measures
639    // OUR input-to-draw latency only — from the instant an input event was
640    // applied to the instant `terminal.draw()` returns (i.e. we've written the
641    // frame's diff to stdout). It does NOT include the terminal emulator /
642    // pty / compositor present time, which on WSL2 is outside our process.
643    // So: a tiny number here + felt lag ⇒ the lag is the WSL2 terminal, not us;
644    // a large number here ⇒ the lag is our draw. Writes one clean line per
645    // rendered keystroke straight to stderr (bypasses tracing, so it does not
646    // add to the debug-log flood). Off by default — zero cost when unset.
647    // Emit the perf line ONLY when stderr is REDIRECTED (not the
648    // alternate-screen terminal). In the TUI, stderr is the same tty as the
649    // rendered display, so a raw `eprintln!` corrupts it: ratatui's diff
650    // can't repair an out-of-band write, and each line's newline scrolls the
651    // alt-screen — leaving fragments of the previous frame (e.g. preview
652    // line-tails) on screen until a force redraw. Require `2>file`.
653    use std::io::IsTerminal;
654    let perf_env = std::env::var_os("LATTICE_PERF_INPUT").is_some();
655    let stderr_redirected = !std::io::stderr().is_terminal();
656    if perf_env && !stderr_redirected {
657        tracing::info!(
658            "LATTICE_PERF_INPUT is set but stderr is the terminal — perf lines are \
659             suppressed to avoid corrupting the TUI display. Re-run with `2>perf.log` \
660             (or any redirect) to capture them."
661        );
662    }
663    let perf_input = perf_env && stderr_redirected;
664    let mut last_input_at: Option<Instant> = None;
665    // Tracks the active buffer + its line count across frames so a buffer
666    // switch that SHRINKS the content can force a full repaint (see the
667    // clear in the draw loop).
668    let mut last_active_buffer: Option<(crate::buffers::BufferId, usize)> = None;
669
670    // I.3 (event-driven wake): replace the 100ms terminal poll with a wake on
671    // (input-ready OR actor-publish). A dedicated reader thread owns terminal
672    // events and forwards each as `Wake::Input`; a tiny task on the shared LSP
673    // runtime forwards the actor's `paint_request` `Notify` (async syntax / LSP
674    // / worker republishes) as `Wake::Repaint`. The loop blocks on a single
675    // channel of `Wake`s, so it draws exactly when something changed and stays
676    // fully idle (zero draws, zero CPU) otherwise — the up-to-100ms async-
677    // repaint lag is gone. The reader polls a stop flag on a 100ms tick (NOT a
678    // redraw timer — it never wakes the loop on its own) purely so it tears
679    // down cleanly on quit. See input-latency.md I.3.
680    let (wake_tx, wake_rx) = mpsc::channel::<Wake>();
681    let reader_stop = std::sync::Arc::new(AtomicBool::new(false));
682    let reader_handle = {
683        let tx = wake_tx.clone();
684        let stop = reader_stop.clone();
685        thread::Builder::new()
686            .name("lattice-tui-input".into())
687            .spawn(move || {
688                while !stop.load(Ordering::Relaxed) {
689                    // Block up to 100ms for an event; on timeout, loop back to
690                    // re-check the stop flag. `poll` returns the instant an
691                    // event is ready, so input latency is NOT capped at 100ms.
692                    match event::poll(Duration::from_millis(100)) {
693                        Ok(true) => match event::read() {
694                            Ok(ev) => {
695                                if tx.send(Wake::Input(ev)).is_err() {
696                                    break; // main loop dropped the receiver
697                                }
698                            }
699                            Err(_) => break,
700                        },
701                        Ok(false) => {}
702                        Err(_) => break,
703                    }
704                }
705            })
706            .expect("spawn input reader thread")
707    };
708    // Forward async `paint_request` notifications onto the same wake channel.
709    // The actor's workers (syntax highlights, cells, virtual-rows) call
710    // `paint_request.notify_one()` after publishing; `Notify` is permit-style,
711    // so a notify that arrives before this re-awaits is not lost. The task
712    // exits when the main loop drops the receiver (`send` errors).
713    {
714        let tx = wake_tx.clone();
715        // App holds the actor handle in production (`cfg(not(test))`) and the
716        // Editor directly in test builds (3c.final.E.swap); both expose the
717        // same shared `paint_request` Notify.
718        #[cfg(not(test))]
719        let paint_request = app.editor_actor.paint_request();
720        #[cfg(test)]
721        let paint_request = app.editor.paint_request.clone();
722        spawn_on_lsp_runtime(async move {
723            loop {
724                paint_request.notified().await;
725                if tx.send(Wake::Repaint).is_err() {
726                    break;
727                }
728            }
729        });
730    }
731    // The main loop owns only the receiver; the two producer clones above keep
732    // the channel alive until both the reader thread and the bridge task exit.
733    drop(wake_tx);
734
735    // Slice 3c.final.B (group 6): lifecycle read via published
736    // substate. `should_quit` flips from inside dispatch (`:q`,
737    // `:wq`, `:qa!`) which republishes at its tail, so the next
738    // iteration's load sees the new value.
739    // Slice 3c.final.E.4: read through App-cached `render_state`.
740    // MO.1: mirrors `ui.mouse` into the terminal's reporting state.
741    // Checked once per iteration (an O(1) typed-option read, not work
742    // proportional to content) and acted on only when it *changes*, so
743    // `:set ui.mouse` takes effect without a restart while the steady
744    // state costs one lookup and no escape sequences.
745    let mut mouse_capture = false;
746    while !app.render_state.load().lifecycle.should_quit {
747        let want_mouse = app
748            .render_state
749            .load()
750            .options
751            .config
752            .get_typed::<lattice_config::MouseEnabled>()
753            .map(|a| *a)
754            .unwrap_or(false);
755        if want_mouse != mouse_capture {
756            set_mouse_capture(want_mouse);
757            mouse_capture = want_mouse;
758        }
759        // Phase 5.8.AF.5 / Slice X1: `run_tick_pending` no longer
760        // runs per-frame here. It moved to `App::apply`'s tail
761        // (`crates/lattice-ui-tui/src/app/dispatch.rs`) so the
762        // drain happens on the keystroke that caused the work,
763        // not in the renderer's per-frame body. Per paramount
764        // goal #1, the UI thread does no I/O / event drain; the
765        // ~30-channel aggregator we used to call here is exactly
766        // the kind of work the spec forbids on this thread.
767        //
768        // Idle LSP arrivals (a response with no keystroke in flight)
769        // drain off-keystroke via the editor actor's `async_landed`
770        // arm, NOT here: every async LSP result fires `async_landed`
771        // (direct-write caches, event-bus forwarders, and — since slice
772        // AW.1 — the channel-delivered action results: gr/gd/K/code-
773        // actions/rename/format/…). The actor runs `run_tick_pending` +
774        // publish + `paint_request` on that wake, which lands here as a
775        // `Wake::Repaint`. See `docs/dev/architecture/lsp-architecture.md`
776        // §12 ("Async-result render-wake").
777        // Update viewport height. The buffer-area band is the
778        // terminal minus the mode line + cmdline/echo row (and the
779        // candidate-list row band, when a picker / completion popup
780        // is up). Within that band, the active *pane* gets only its
781        // share -- horizontal/vertical splits divide the area, and
782        // multi-pane layouts reserve the bottom row of each pane
783        // for its status line. The viewport must reflect the active
784        // pane's content height, not the full buffer band, so
785        // motions / scroll / cursor visibility agree with what the
786        // renderer actually paints.
787        let size = terminal.size().context("query terminal size")?;
788        // `chrome_rows` is the single source of truth for tabline +
789        // picker/completion candidate-band rows, shared with
790        // `draw_frame`'s paint layout (see its doc comment for the
791        // regression this closes: this loop used to hand-duplicate the
792        // candidate-band formula and omit the tabline row entirely, so
793        // the viewport logic believed it had one more row than
794        // `draw_frame` actually painted once the tabline showed).
795        let chrome = crate::render::chrome_rows(&app);
796        // Option A: global modeline removed; each pane has its own
797        // 1-row status footer. Subtract cmdline (1), the tabline (0/1),
798        // and any picker/completion candidate band.
799        let buffer_height = size
800            .height
801            .saturating_sub(1)
802            .saturating_sub(chrome.tabline)
803            .saturating_sub(chrome.extra()) as u32;
804        // Diff-then-push: only dispatch when the resolved viewport height
805        // changed. Unconditional dispatch here was publishing (+ waking both
806        // cells/virtual-rows workers) every loop iteration.
807        let vh = app.active_pane_content_height(buffer_height);
808        if last_viewport_height != Some(vh) {
809            app.set_viewport_height(vh);
810            last_viewport_height = Some(vh);
811        }
812        // 2026-05-27: fire `set_pane_viewport(idx, rows, cols)` per
813        // leaf so the host writes the per-pane geometry onto
814        // `PaneState` AND, for terminal panes, resizes alacritty +
815        // PTY to match the allocated area. Without this, terminal
816        // grids stayed at their spawn-time 80×24 default and never
817        // wrapped to the actual TUI pane width — the GPUI peer
818        // had this loop in its render path but the TUI never did.
819        //
820        // Diff-then-send: only send when the leaf's published
821        // viewport differs from what compute_rects computed this
822        // frame. Steady-state (no resize) fires zero commands.
823        let panes_arc = app.panes();
824        let area_for_panes = crate::pane::PaneRect {
825            x: 0,
826            y: 0,
827            width: size.width,
828            height: buffer_height as u16,
829        };
830        let rects = panes_arc.tree.compute_rects(area_for_panes);
831        let current_leaves: Vec<_> = panes_arc.tree.leaves().to_vec();
832        for (idx, prect) in &rects {
833            // Every pane reserves a bottom row for its status line
834            // (Option A: global modeline removed). Mirror of
835            // draw_panes which unconditionally splits rect.height >= 2.
836            let content_h = if prect.height >= 2 {
837                prect.height - 1
838            } else {
839                prect.height
840            };
841            let rows = u32::from(content_h).max(1);
842            let cols = u32::from(prect.width).max(1);
843            let needs = current_leaves
844                .get(*idx)
845                .map(|l| l.viewport_height != rows || l.viewport_width != cols)
846                .unwrap_or(true);
847            if needs {
848                app.set_pane_viewport(*idx, rows, cols);
849            }
850        }
851        // PU.1b-3: floating-popup inner-geometry hand-off. Mirror of the
852        // per-pane loop above: the renderer is the sizing authority, so it
853        // pushes the popup's resolved inner `(rows, cols)` to the host for
854        // `build_cells_panes` to size the synthetic popup-pane matrix.
855        // `popup_feedback_inner_dims` recomputes the SAME geometry
856        // `draw_help_overlay` paints into (`popup_outer_size` over the
857        // buffer area minus tabline/cmdline/candidate rows), so the matrix
858        // and the painted box agree on width. Diff-then-send: a steady-state
859        // popup fires zero RPCs; closing the popup pushes `None` once.
860        // WK.12: the band's geometry hand-off, beside the popup's.
861        let band_dims = crate::render::band_feedback_inner_dims(&app, size.width, buffer_height);
862        if last_band_dims != band_dims {
863            if let Some((rows, cols)) = band_dims {
864                app.set_band_viewport(rows, cols);
865            }
866            last_band_dims = band_dims;
867        }
868        let popup_dims = crate::render::popup_feedback_inner_dims(&app, size.width, buffer_height);
869        if last_popup_dims != popup_dims {
870            if let Some((rows, cols)) = popup_dims {
871                app.set_popup_viewport(rows, cols);
872            }
873            last_popup_dims = popup_dims;
874        }
875        // Slice 3c.final.C: terminal_width via Action. Diff-then-send
876        // (mirrors the pane-viewport loop above): the width only changes
877        // on a terminal resize, so dispatching every frame meant a blocking
878        // actor RPC + a full `publish_render_state` on every frame (hence on
879        // every keystroke's draw) for a no-op field write. Cache the
880        // last-sent width UI-side; dispatch only on change.
881        if last_terminal_width != Some(size.width) {
882            app.apply(lattice_host::action::Action::SetTerminalWidth(size.width));
883            last_terminal_width = Some(size.width);
884        }
885        // `<C-l>` (RedrawScreen) sets `pending_redraw`; honour it
886        // by clearing the terminal buffer so the next draw repaints
887        // every cell instead of letting ratatui's diff engine
888        // assume the previous frame's contents are intact.
889        // Slice 3c.final.B (group 6) + 3c.final.C: pending_redraw
890        // read via the published substate; acknowledge-write goes
891        // through `Action::AcknowledgeRedraw` so the renderer no
892        // longer mutates editor state directly. Dispatch tail
893        // republishes RS so the next iteration's load observes
894        // the cleared flag.
895        // Slice 3c.final.E.4: read through App-cached `render_state`.
896        if app.render_state.load().lifecycle.pending_redraw {
897            terminal.clear().context("clear terminal for redraw")?;
898            app.apply(lattice_host::action::Action::AcknowledgeRedraw);
899        }
900        // Force a full repaint when a buffer switch SHRINKS the content. A
901        // switch to a shorter buffer (picker live-preview flipping to a
902        // smaller file, the binary "<no preview>" placeholder, or the
903        // no-match restore to the empty origin) leaves rows the previous
904        // buffer painted that the new (shorter) content can't cover; ratatui
905        // leaves those stale in the live async run — the preview "bleeding"
906        // (line-tail fragments surviving until a manual `<C-l>`). Clearing
907        // makes the next draw repaint every cell, like RedrawScreen.
908        //
909        // Scoped to SHRINK only: switching among full-screen buffers (the
910        // common preview-typing case) grows/keeps the line count, so it
911        // never clears → no flicker. Within-row trailing cells already clear
912        // for rows present in both frames; only the vacated rows bleed.
913        let active_buffer = app.active_buffer_id();
914        let line_count = app.ad().snapshot.buffer.content_line_count() as usize;
915        if last_active_buffer.is_some_and(|(prev_id, prev_lines)| {
916            prev_id != active_buffer && line_count < prev_lines
917        }) {
918            terminal.clear().context("clear terminal on shrink")?;
919        }
920        last_active_buffer = Some((active_buffer, line_count));
921        // Phase 5.8.AF.5 / Slice X2.5: removed
922        // `app.refresh_highlights()` from the per-frame body.
923        // display-line B4.2: active-pane syntax colour now flows
924        // through the cells / `DisplayMatrix` substrate (rebuilt off
925        // the UI thread by the cells worker), and overlay backgrounds
926        // through the `lattice_host::overlay_worker` (woken via
927        // `Editor::overlay_wake`). Both keep the per-frame body free
928        // of any tree-sitter walk. Pre-X2 cost: 200–600µs per frame
929        // on scroll cache miss (tree-sitter walk on UI thread);
930        // now zero UI-thread parse cost. Goal #1 violation B1 closed
931        // for the TUI peer.
932        // B4: the per-frame UI-thread `refresh_pane_highlights`
933        // recompute was disabled then deleted (it only fed the dead
934        // inactive-pane `pane_highlights` cache; inactive panes read
935        // their retained per-pane `DisplayMatrix`).
936
937        // Push the cursor shape only when the inputs change --
938        // terminals accept these every frame, but emitting on every
939        // iteration adds a few bytes of escape sequence to the
940        // stream that isn't free. Cache key includes both `modal`
941        // and `terminal_insert_active` so the bar / block flip on
942        // entering / leaving TerminalInsert re-pushes.
943        let cursor_inputs = (app.ad().modal, app.ad().terminal_insert_active);
944        if last_cursor_inputs != Some(cursor_inputs) {
945            execute!(
946                terminal.backend_mut(),
947                cursor_style_for(cursor_inputs.0, cursor_inputs.1)
948            )
949            .context("set cursor style")?;
950            last_cursor_inputs = Some(cursor_inputs);
951        }
952
953        // §5.6.8: one Cache::load per frame for the active document.
954        // Steady-state ~300ps when the actor hasn't published since
955        // last frame; ~16ns on the first read after a publish.
956        // The Arc keeps the snapshot alive for the entire frame --
957        // the actor is free to publish concurrently and the new
958        // pointer is observed by next frame's load.
959        // Slice 3c.final.E.5j: per-frame snapshot read via the
960        // published `ad().snapshot` mirror (Arc-bump clone off the
961        // same source as `snapshot_cache.load_arc()`).
962        let frame_snap = app.ad().snapshot.clone();
963        let draw_t0 = if perf_input {
964            // Reset the per-frame byte tally so we count only THIS draw's
965            // write (the cursor-style execute! above already happened).
966            PERF_FRAME_BYTES.store(0, Ordering::Relaxed);
967            Some(Instant::now())
968        } else {
969            None
970        };
971        // Pin the ActiveDocumentRenderState snapshot so every
972        // `app.ad()` call inside the draw sees the same cursor,
973        // scroll, option, and modal state — without the pin the
974        // actor can publish a new RenderState between any two
975        // independent ad() calls, causing the cursorline highlight
976        // and cursor blink position to disagree.
977        app.pin_render_state();
978        let mut completion_docs_dims: Option<(u32, u32)> = None;
979        terminal
980            .draw(|frame| {
981                completion_docs_dims = draw_frame(frame, &app, &frame_snap);
982            })
983            .context("draw frame")?;
984        app.unpin_render_state();
985        // PU.5c: completion-docs popup geometry hand-off (diff-then-send,
986        // peer of the floating-popup feedback above). `draw_frame` returns
987        // the docs popup's inner (rows, cols) when shown — computed at the
988        // exact draw site (it is cursor-anchored, so position-dependent) —
989        // and the host sizes the `PaneId::COMPLETION_DOCS` matrix from it.
990        // `None` when no docs popup → nothing sent (the synthetic pane just
991        // isn't built).
992        if last_completion_docs_dims != completion_docs_dims {
993            if let Some((rows, cols)) = completion_docs_dims {
994                app.set_completion_docs_viewport(rows, cols);
995            }
996            last_completion_docs_dims = completion_docs_dims;
997        }
998        // Perf timer: this draw is the one that renders the keystroke applied
999        // at the end of the PREVIOUS iteration. Report input→glyph (our side),
1000        // the bare draw duration, AND the bytes written to the terminal — the
1001        // last is the real driver of present time on a slow pty (vim writes a
1002        // handful; a whole-viewport rewrite is kilobytes). Clear so we only log
1003        // frames that actually rendered fresh input (not async repaints).
1004        if perf_input && let (Some(input_at), Some(t0)) = (last_input_at.take(), draw_t0) {
1005            let done = Instant::now();
1006            eprintln!(
1007                "[perf] input→glyph {:>7.3}ms  (draw {:>7.3}ms, {} bytes)",
1008                done.duration_since(input_at).as_secs_f64() * 1e3,
1009                done.duration_since(t0).as_secs_f64() * 1e3,
1010                PERF_FRAME_BYTES.load(Ordering::Relaxed),
1011            );
1012        }
1013
1014        // I.3 (event-driven wake): block until the input reader forwards a
1015        // terminal event or the actor's `paint_request` forwards a repaint,
1016        // then drain every wake already buffered before looping back to draw
1017        // once. A typing burst collapses to N applies + ONE draw (I.1's
1018        // coalescing, now over the wake channel — the mockable seam I.1
1019        // deferred to here). `recv()` parks the thread with zero CPU while
1020        // idle, so an idle editor issues zero draws and the up-to-100ms
1021        // async-repaint lag is gone. The translate context is rebuilt per key
1022        // inside `apply_event` because applying one event can change the modal
1023        // state / mode stack that governs the next event's translation. See
1024        // docs/dev/architecture/input-pipeline.md.
1025        match wake_rx.recv() {
1026            Ok(first) => {
1027                let repaint_only =
1028                    drain_wakes(&mut app, &wake_rx, first, perf_input, &mut last_input_at);
1029                // X1b: repaint-only wakes (hover response, syntax reparse,
1030                // search excerpts) need a tick drain so the pending channel
1031                // is read before the next draw. Keystrokes drain it already
1032                // at the apply tail; this path covers the idle-arrival gap.
1033                if repaint_only {
1034                    app.drain_async_pending();
1035                }
1036            }
1037            // Both producer clones are gone (the reader thread died and the
1038            // paint bridge exited) — nothing can wake us again; leave the loop.
1039            Err(_) => break,
1040        }
1041    }
1042    // I.3 teardown: stop the reader thread and join it before `run` restores
1043    // the terminal, so no detached thread is left reading stdin once raw mode
1044    // is disabled. The reader observes the flag within its 100ms poll tick.
1045    reader_stop.store(true, Ordering::Relaxed);
1046    let _ = reader_handle.join();
1047    Ok(())
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    #![allow(clippy::unwrap_used, clippy::panic)]
1053    use super::*;
1054    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1055    use lattice_grammar::{SearchDirection, VisualKind};
1056
1057    /// Build a plain `Event::Key` for a character (no modifiers).
1058    fn key(c: char) -> Event {
1059        Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
1060    }
1061
1062    /// I.3: `drain_wakes` applies every buffered input event in one batch —
1063    /// the coalescing the event-driven loop relies on for the keystroke UX
1064    /// contract (N queued keys → N applies → ONE draw) — and arms the perf
1065    /// timer because real input was applied. The wake channel is the mockable
1066    /// event source I.1's burst test was deferred to.
1067    #[test]
1068    fn drain_wakes_applies_and_coalesces_buffered_input() {
1069        let mut app = App::new(Document::from_text("hello\n"));
1070        let (tx, rx) = mpsc::channel::<Wake>();
1071        // Queue a burst: `i` enters Insert, `X` types a char.
1072        tx.send(Wake::Input(key('i'))).unwrap();
1073        tx.send(Wake::Input(key('X'))).unwrap();
1074        let first = rx.recv().unwrap();
1075        let mut last_input_at = None;
1076        let _ = drain_wakes(&mut app, &rx, first, true, &mut last_input_at);
1077        // Whole burst drained in one batch — nothing left for a second draw.
1078        assert!(rx.try_recv().is_err(), "burst must coalesce into one drain");
1079        // Real input applied → perf timer armed + modal advanced to Insert.
1080        assert!(
1081            last_input_at.is_some(),
1082            "applied input must arm the perf timer"
1083        );
1084        assert_eq!(app.ad().modal, ModalState::Insert, "`i` must enter Insert");
1085    }
1086
1087    // --- ML.4: modeline clicks ----------------------------------------
1088
1089    /// Build a left-button press at `(col, row)`.
1090    fn click(col: u16, row: u16) -> Event {
1091        Event::Mouse(crossterm::event::MouseEvent {
1092            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
1093            column: col,
1094            row,
1095            modifiers: KeyModifiers::NONE,
1096        })
1097    }
1098
1099    /// A click that lands on a recorded region dispatches that region's
1100    /// command through the ordinary invocation path — the same one a
1101    /// keystroke bound to the action would take.
1102    #[test]
1103    fn a_click_on_a_recorded_region_invokes_its_command() {
1104        let mut app = App::new(Document::from_text("hello world\n"));
1105        // `:` opens the command line — a visible, cheap-to-assert effect
1106        // reachable from a CommandId without standing up a mode.
1107        let command = app.editor.builtins.word_forward.0;
1108        app.modeline_hits
1109            .borrow_mut()
1110            .push(lattice_host::modeline::ModelineHitZone {
1111                row: 23,
1112                col_start: 4,
1113                col_end: 9,
1114                action: command,
1115            });
1116
1117        let mut last_input_at = None;
1118        apply_event(&mut app, click(5, 23), true, &mut last_input_at);
1119
1120        assert_eq!(
1121            app.ad().cursor.byte,
1122            6,
1123            "the click must dispatch the element's command (w → next word)"
1124        );
1125        assert!(
1126            last_input_at.is_some(),
1127            "a click that did something is real input"
1128        );
1129    }
1130
1131    /// A click on dead space is a no-op — not a swallowed key, not a
1132    /// spurious perf-timer arm.
1133    #[test]
1134    fn a_click_outside_every_region_does_nothing() {
1135        let mut app = App::new(Document::from_text("hello\n"));
1136        let command = app.editor.builtins.word_forward.0;
1137        app.modeline_hits
1138            .borrow_mut()
1139            .push(lattice_host::modeline::ModelineHitZone {
1140                row: 23,
1141                col_start: 4,
1142                col_end: 9,
1143                action: command,
1144            });
1145
1146        let mut last_input_at = None;
1147        apply_event(&mut app, click(20, 23), true, &mut last_input_at);
1148
1149        assert_eq!(app.ad().cursor.byte, 0, "cursor must not move");
1150        assert!(
1151            last_input_at.is_none(),
1152            "a click on nothing must not be counted as input"
1153        );
1154    }
1155
1156    /// Only the left button acts. Right / middle are reserved (a context
1157    /// menu is a plausible future) and wheel events must never be
1158    /// mistaken for clicks — scrolling over a modeline would otherwise
1159    /// fire its command repeatedly.
1160    #[test]
1161    fn non_left_button_events_are_ignored() {
1162        let mut app = App::new(Document::from_text("hello\n"));
1163        let command = app.editor.builtins.word_forward.0;
1164        app.modeline_hits
1165            .borrow_mut()
1166            .push(lattice_host::modeline::ModelineHitZone {
1167                row: 23,
1168                col_start: 4,
1169                col_end: 9,
1170                action: command,
1171            });
1172
1173        let mut last_input_at = None;
1174        for kind in [
1175            crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right),
1176            crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
1177            crossterm::event::MouseEventKind::ScrollDown,
1178            crossterm::event::MouseEventKind::Moved,
1179        ] {
1180            apply_event(
1181                &mut app,
1182                Event::Mouse(crossterm::event::MouseEvent {
1183                    kind,
1184                    column: 5,
1185                    row: 23,
1186                    modifiers: KeyModifiers::NONE,
1187                }),
1188                true,
1189                &mut last_input_at,
1190            );
1191            assert_eq!(app.ad().cursor.byte, 0, "{kind:?} must not dispatch");
1192        }
1193    }
1194
1195    /// I.3: a `Repaint` wake (forwarded from `paint_request`) drives the
1196    /// redraw at the loop top but applies NO input — it must not arm the perf
1197    /// timer or mutate modal state. This is what keeps an async republish
1198    /// (syntax recolour, LSP) repainting promptly without being mistaken for a
1199    /// keystroke.
1200    #[test]
1201    fn drain_wakes_repaint_applies_no_input() {
1202        let mut app = App::new(Document::from_text("hello\n"));
1203        let (_tx, rx) = mpsc::channel::<Wake>();
1204        let mut last_input_at = None;
1205        let repaint_only = drain_wakes(&mut app, &rx, Wake::Repaint, true, &mut last_input_at);
1206        assert!(repaint_only, "repaint batch must return repaint_only=true");
1207        assert!(last_input_at.is_none(), "repaint must not look like input");
1208        assert_eq!(
1209            app.ad().modal,
1210            ModalState::Normal,
1211            "repaint must not change modal"
1212        );
1213    }
1214
1215    /// `<Esc>` dismisses an open floating popup even when the DOCUMENT keeps
1216    /// focus (State A) — the GPUI peer already does this; the TUI used to
1217    /// only close on State-B Esc or cursor motion. Regression guard for the
1218    /// user-reported "Esc doesn't close the hover popup" bug.
1219    #[test]
1220    fn esc_dismisses_floating_popup_in_state_a() {
1221        let mut app = App::new(Document::from_text("fn main() {}\n"));
1222        let content = crate::help::HelpContent::from_lines("hover", vec!["doc".to_string()]);
1223        app.open_floating_popup(
1224            content,
1225            lattice_core::ui::popup::PopupPlacement::CursorAnchored,
1226        );
1227        assert!(app.popup().is_open(), "floating popup open");
1228        assert_ne!(
1229            app.ad().buffer_kind,
1230            crate::buffers::BufferKind::Help,
1231            "State A: document keeps focus (popup not focused)"
1232        );
1233        let mut last = None;
1234        apply_event(
1235            &mut app,
1236            Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1237            false,
1238            &mut last,
1239        );
1240        assert!(
1241            !app.popup().is_open(),
1242            "Esc over the document must dismiss the floating popup (State A)"
1243        );
1244    }
1245
1246    #[test]
1247    fn esc_closes_a_help_split_pane() {
1248        // `help.describe-display=split-h` opens help in its OWN split pane.
1249        // `<Esc>` — now owned by help-mode's keymap — must CLOSE that pane and
1250        // return to the sibling, NOT restore a buffer into it (the reported
1251        // "Esc should close help buffers even when opened in splits"). Driven
1252        // end-to-end through `apply_event` so the whole path is exercised: the
1253        // help-mode `<Esc>` keymap → `action:help-dismiss` → `Effect::DismissPopup`
1254        // → `dismiss_popup`'s split-close branch. `App::new` runs `Editor::boot`,
1255        // so the mode keymap + the command are wired exactly as in production.
1256        let mut app = App::new(Document::from_text("fn main() {}\n"));
1257        let content = crate::help::HelpContent::from_lines("describe", vec!["body".to_string()]);
1258        app.open_help_in_split(
1259            content,
1260            lattice_core::ui::pane::SplitOrientation::Horizontal,
1261        );
1262        assert_eq!(
1263            app.editor.pane_tree.len(),
1264            2,
1265            "split-h opens a second pane holding help"
1266        );
1267        assert_eq!(app.editor.active_buffer, crate::buffers::BufferKind::Help);
1268
1269        let mut last = None;
1270        apply_event(
1271            &mut app,
1272            Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1273            false,
1274            &mut last,
1275        );
1276
1277        assert_eq!(
1278            app.editor.pane_tree.len(),
1279            1,
1280            "Esc must CLOSE the help split pane, not restore a buffer into it"
1281        );
1282        assert_ne!(
1283            app.editor.active_buffer,
1284            crate::buffers::BufferKind::Help,
1285            "focus returns to the surviving (document) pane"
1286        );
1287    }
1288
1289    #[test]
1290    fn normal_mode_uses_block_cursor() {
1291        assert!(matches!(
1292            cursor_style_for(ModalState::Normal, false),
1293            SetCursorStyle::SteadyBlock
1294        ));
1295    }
1296
1297    #[test]
1298    fn insert_mode_uses_bar_cursor() {
1299        assert!(matches!(
1300            cursor_style_for(ModalState::Insert, false),
1301            SetCursorStyle::SteadyBar
1302        ));
1303    }
1304
1305    #[test]
1306    fn visual_charwise_uses_block_cursor() {
1307        assert!(matches!(
1308            cursor_style_for(ModalState::Visual(VisualKind::Charwise), false),
1309            SetCursorStyle::SteadyBlock
1310        ));
1311    }
1312
1313    #[test]
1314    fn visual_linewise_uses_block_cursor() {
1315        assert!(matches!(
1316            cursor_style_for(ModalState::Visual(VisualKind::Linewise), false),
1317            SetCursorStyle::SteadyBlock
1318        ));
1319    }
1320
1321    #[test]
1322    fn operator_pending_uses_block_cursor() {
1323        assert!(matches!(
1324            cursor_style_for(ModalState::OperatorPending, false),
1325            SetCursorStyle::SteadyBlock
1326        ));
1327    }
1328
1329    #[test]
1330    fn replace_mode_uses_underscore_cursor() {
1331        assert!(matches!(
1332            cursor_style_for(ModalState::Replace, false),
1333            SetCursorStyle::SteadyUnderScore
1334        ));
1335    }
1336
1337    #[test]
1338    fn command_mode_uses_bar_cursor() {
1339        assert!(matches!(
1340            cursor_style_for(ModalState::Command, false),
1341            SetCursorStyle::SteadyBar
1342        ));
1343    }
1344
1345    #[test]
1346    fn search_mode_uses_bar_cursor() {
1347        assert!(matches!(
1348            cursor_style_for(ModalState::Search(SearchDirection::Forward), false),
1349            SetCursorStyle::SteadyBar
1350        ));
1351        assert!(matches!(
1352            cursor_style_for(ModalState::Search(SearchDirection::Backward), false),
1353            SetCursorStyle::SteadyBar
1354        ));
1355    }
1356
1357    /// Terminal-mode T2.b: `terminal_insert_active` forces the
1358    /// shape to `SteadyBar` regardless of `ModalState` (which
1359    /// stays `Normal` because TerminalInsert is a minor mode).
1360    /// Normal-in-terminal (insert-active = false) stays
1361    /// `SteadyBlock` so the user can tell at a glance which
1362    /// sub-state they're in.
1363    #[test]
1364    fn terminal_insert_minor_mode_overrides_to_bar() {
1365        assert!(matches!(
1366            cursor_style_for(ModalState::Normal, true),
1367            SetCursorStyle::SteadyBar
1368        ));
1369        assert!(matches!(
1370            cursor_style_for(ModalState::Normal, false),
1371            SetCursorStyle::SteadyBlock
1372        ));
1373    }
1374}
1375
1376/// OS.1: the keyboard-enhancement gate.
1377///
1378/// The push itself needs a tty and cannot be tested here; the DECISION can,
1379/// which is why it is a separate function. Both conditions must hold, and the
1380/// two failure directions are different bugs: pushing when the user turned it
1381/// off ignores their config, and pushing when the terminal does not support it
1382/// sends an escape sequence into a terminal that will render it as garbage.
1383#[cfg(test)]
1384mod os1_tests {
1385    use super::should_push_enhancement;
1386
1387    #[test]
1388    fn the_option_off_means_no_push_even_when_supported() {
1389        assert!(!should_push_enhancement(false, true));
1390    }
1391
1392    #[test]
1393    fn an_unsupporting_terminal_is_not_pushed_to() {
1394        assert!(!should_push_enhancement(true, false));
1395    }
1396
1397    #[test]
1398    fn a_supporting_terminal_with_the_option_on_is_pushed_to() {
1399        assert!(should_push_enhancement(true, true));
1400    }
1401}