Skip to main content

lattice_ui_tui/app/
boot.rs

1//! Boot / config-load / sync paths the runtime calls before the
2//! main loop starts -- the App's once-per-launch infrastructure.
3//!
4//! Methods that live here:
5//! - `App::new` (the once-per-launch constructor). Phase 5.7.B.1
6//!   delegates the renderer-neutral boot to
7//!   [`lattice_host::editor::Editor::boot`]; this method only
8//!   builds the renderer wrapper and runs renderer-side
9//!   post-boot wiring.
10//! - `sync_keymap_overlays` (re-stack the popup / snippet
11//!   minor-mode keymap layers in lockstep with overlay state).
12//! - `sync_theme_from_config` (re-derive `App.theme`'s renderer-
13//!   specific `Style` values from `ui.*` typed options).
14//! - `load_persistent_config` (read user + project TOML and
15//!   apply scalar overrides + bucket structural sub-tables).
16//!
17//! What does NOT live here: the option resolver itself
18//! (`lattice-config`), the keymap registry
19//! (`crate::keymap_registry`), the theme parser
20//! (`crate::theme`). This module is the App's *boot wiring*
21//! over those.
22
23use lattice_core::Document;
24
25use super::{App, BufferKind};
26
27impl App {
28    pub fn new(document: Document) -> Self {
29        // DB.5 (design.md §9.1): capture the opened-file path BEFORE
30        // `document` moves into `Editor::boot` (which consumes it) — this
31        // is the only point where the renderer still owns the `Document`.
32        let opened_file = document.path().map(|p| p.to_path_buf());
33        // Phase 5.7.B.1: the renderer-neutral construction body
34        // moved to `lattice_host::editor::Editor::boot`.
35        let mut editor = lattice_host::editor::Editor::boot(document);
36        // CB.2 (docs/dev/architecture/clipboard.md): override the
37        // FakeClipboard CB.0 registers by default with the TUI's real
38        // backend (native arboard when available, OSC52 write-only
39        // fallback otherwise). Must run before anything else can have
40        // cloned `editor.services` -- `Editor::boot` hands back a fresh
41        // Arc (built via `BootContext`'s owned, non-Arc `ServiceRegistry`
42        // during boot, wrapped only at the very end), so `Arc::get_mut`
43        // is guaranteed to succeed here.
44        if let Some(services) = std::sync::Arc::get_mut(&mut editor.services) {
45            let clipboard: lattice_core::ClipboardHandle = crate::clipboard::boot_backend();
46            services.register(clipboard);
47        } else {
48            debug_assert!(
49                false,
50                "editor.services should be uniquely owned immediately after Editor::boot"
51            );
52        }
53        // DB.5 (test isolation): unit tests construct `App::new` with a
54        // pathless `Document::from_text`, which looks exactly like a
55        // real no-file launch — so the startup trigger below would
56        // auto-open `*dashboard*` and every render test would see the
57        // dashboard buffer instead of its own text. Disable the auto-open
58        // BEFORE the `Startup` publish: the trigger's async task reads
59        // `dashboard.enabled` only AFTER it receives `Startup`, and its
60        // `recv()` cannot complete before `publish_typed` sends — so
61        // setting it here is race-free (any later `set` would race the
62        // background task). Production launch (`main`) never takes this
63        // branch, so the real no-file→dashboard behavior is untouched.
64        #[cfg(test)]
65        {
66            let _ = editor
67                .config
68                .parse_and_set_command("dashboard.enabled=false");
69        }
70        // DB.5: publish `Startup` once `editor` exists (right after `boot`
71        // returns), so `lattice_dashboard::install`'s subscription (wired
72        // during `boot`, Phase-B) can decide whether to auto-open
73        // `*dashboard*`. One publish per boot, TUI + GPUI both wire this at
74        // their own post-boot seam; the subscription itself lives once in
75        // `lattice-dashboard`.
76        editor
77            .event_bus
78            .publish_typed(lattice_mode::Startup { opened_file });
79        // Slice 3c.atomic.A: renderer-side clone of the editor's
80        // RenderState cell, captured before Editor moves to the
81        // actor thread.
82        let render_state = editor.render_state.clone();
83        // Perf plan B.2 slice B.2.a: clone the overlay worker's
84        // output cell. display-line B4.2: the dead span/row prepaint
85        // cell clones were deleted with the worker's span/row cache.
86        let syntax_static_overlay_quads_cell = editor.syntax_static_overlay_quads_cell.clone();
87        // Slice 3c.final.E.swap: run boot-time setup directly on
88        // the owned Editor BEFORE handing it to the actor. Every
89        // call below resolves to a host-side method; the App-side
90        // wrappers that previously routed through `mutate_editor`
91        // would all funnel through the actor's mailbox, which
92        // doesn't exist yet at this point in construction.
93        editor.sync_host_theme_from_config();
94        editor.rebuild_option_cache();
95        let doc_buf = editor.document_buffer_id;
96        let _ = editor.activate_major_for_buffer_kind(doc_buf, BufferKind::Document);
97        editor.publish_document_opened_for_active();
98        editor.ensure_named_synthetic_document(
99            lattice_lsp::LSP_SUBSYSTEM_LOG_NAME,
100            lattice_lsp::modes::LspLogMode::mode_id(),
101            crate::app::App::SYNTHETIC_BUFFER_FLAGS,
102        );
103        editor.ensure_messages_buffer();
104        // Slice 3c.atomic.B: initial RS publish so `app.ad()`
105        // returns boot-time editor state, not the Default.
106        editor.publish_render_state();
107
108        // Slice 3c.final.E.swap: hand Editor to the actor thread
109        // (prod) or keep it inline (test). The cfg-gate is the
110        // architectural split — production code can only reach
111        // Editor through the actor handle's blocking RPCs, while
112        // test code retains direct field access for fixtures that
113        // mutate state without going through the dispatch path.
114        #[cfg(not(test))]
115        let editor_field = lattice_host::editor_actor::spawn_editor_actor(editor);
116        #[cfg(test)]
117        let editor_field = editor;
118
119        let mut app = Self {
120            #[cfg(not(test))]
121            editor_actor: editor_field,
122            #[cfg(test)]
123            editor: editor_field,
124            render_state,
125            frame_ad: std::sync::Mutex::new(None),
126            syntax_static_overlay_quads_cell,
127            pane_render_registry: crate::render::build_pane_render_registry(),
128            theme: crate::theme::Theme::default(),
129            modeline_hits: std::cell::RefCell::new(lattice_host::modeline::ModelineHitMap::new()),
130            pane_hits: std::cell::RefCell::new(lattice_host::mouse::PaneHitMap::new()),
131        };
132        // App-side post-actor setup: rebuild the cached TUI theme
133        // from the freshly-published `render_state.theme`. Reads
134        // the published RS (already primed above), no editor borrow.
135        app.rebuild_tui_theme();
136        app
137    }
138
139    /// Re-stack the Insert-mode minor-mode overlays
140    /// (completion popup + active snippet) so the layered
141    /// keymap registry mirrors the App's overlay state. Called
142    /// from the apply loop after every `Action`; cheap when
143    /// nothing changed (single mutex acquisition + early
144    /// return).
145    ///
146    /// Push order is enforced here so popup always sits at the
147    /// top of the stack when both overlays are active: the
148    /// method pops everything, then pushes snippet (if active),
149    /// then popup (if active). Popup's `LayerId` is therefore
150    /// always higher than snippet's, and popup wins on
151    /// overlapping chords (preserving the legacy "popup
152    /// precedes snippet" gating in `input::translate`).
153    ///
154    /// Slice 8.f.
155    pub fn sync_keymap_overlays(&mut self) {
156        // Slice 3c.final.E.5e: body hoisted to
157        // [`lattice_host::dispatch::Editor::sync_keymap_overlays`].
158        // Renderer delegates through `mutate_editor` so post-swap
159        // the closure crosses the actor channel like every other
160        // mutation.
161        self.mutate_editor(|e| e.sync_keymap_overlays());
162    }
163
164    // Slice 3c.final.E.5e: `sync_completion_popup_mode_activation`
165    // retired -- inlined into
166    // [`lattice_host::dispatch::Editor::sync_keymap_overlays`]
167    // alongside its sole caller. Original doc-comment preserved
168    // for grep:
169    //
170    // CSM.K1: bring `completion-popup-mode`'s activation state
171    // on the active document buffer in line with `want_popup`.
172    // Called from `sync_keymap_overlays` so the transient
173    // popup-mode tracks the popup open / close transitions
174    // without each `self.editor.insert_completion = ...` site having
175    // to know about it.
176    //
177    // Per-buffer scope: the popup belongs to the document the
178    // user is typing in. v1 has a single document buffer
179    // (`self.document_buffer_id()`); multi-document support
180    // activates this mode on whichever doc owns the popup at
181    // open time when that lands. Deactivation is symmetric.
182    // (Body retired: inlined into the host-side method.)
183
184    /// Re-derive `App.theme`'s renderer-specific `Style` values
185    /// from the current `ui.*` option values in the config. Called
186    /// at App-init time (after registration) and on every `:set
187    /// ui.*` so the cached theme stays in lockstep with the
188    /// canonical primitives in config.
189    pub fn sync_theme_from_config(&mut self) {
190        // Phase 5.5.E.6: the renderer-neutral half (read typed
191        // options + write `editor.host_theme`) lives on the host
192        // as `Editor::sync_host_theme_from_config`. Splitting the
193        // function lets the option-cascade in `Editor::apply_option_cascade`
194        // run the host half directly and emit `RendererSignal::ThemeChanged`;
195        // the renderer (here) only owns the cached TUI-typed
196        // mirror rebuild.
197        // Slice 3c.final.E.3: route through `mutate_editor`.
198        self.mutate_editor(|e| e.sync_host_theme_from_config());
199        self.rebuild_tui_theme();
200    }
201
202    /// Rebuild the cached TUI-typed [`crate::theme::Theme`] from
203    /// the renderer-neutral [`lattice_host::ui::theme::Theme`] **plus**
204    /// the resolved theme table (T.4). Cheap (every field is `Copy`);
205    /// the rebuild fires only on option cascade or on a host-emitted
206    /// [`lattice_host::dispatch::RendererSignal::ThemeChanged`],
207    /// never per frame — so a `:colorscheme` / palette swap (which
208    /// bumps `ResolvedTheme::version()` and emits `ThemeChanged`)
209    /// recolors the cache without any per-frame style adaptation. A
210    /// future GPUI renderer implements an equivalent
211    /// `rebuild_gpui_theme` on its own `App`.
212    pub fn rebuild_tui_theme(&mut self) {
213        let rs = self.render_state.load();
214        // T.6.t: non-style chrome (glyphs, separator chars, dim/
215        // nerd-fonts flags) sources from the published typed-options
216        // registry; style fields from the resolved table.
217        self.theme =
218            crate::theme::build_tui_theme(&rs.options.config, &rs.resolved_theme, &rs.theme_ids);
219    }
220
221    /// Load `~/.editor.config/lattice/lattice.toml` (user) and
222    /// `<workspace_root>/.lattice/config.toml` (project) in
223    /// precedence order, applying scalar overrides to
224    /// `self.editor.config` and bucketing structural sub-tables (per-
225    /// language overrides, plugin sections) into
226    /// `self.editor.pending_config_structural_sections` for their
227    /// owners to drain.
228    ///
229    /// Called once by the runtime startup before the main loop
230    /// (so the first frame already reflects user overrides).
231    /// NOT called from `App::new` -- tests stay isolated from
232    /// the user's real `~/.editor.config/lattice/`. Test fixtures that
233    /// want to exercise the load path can call this directly
234    /// with a synthesized workspace root.
235    ///
236    /// Loader diagnostics (parse errors, unknown keys,
237    /// validation rejects) collapse into a single echo at the
238    /// most-severe level: `Error` if any file failed to
239    /// parse / read, `Warn` if any key was rejected, otherwise
240    /// silent. Per-file `path:body` detail rides the message
241    /// body so the user can see *which* file complained.
242    pub fn load_persistent_config(&mut self, workspace_root: Option<&std::path::Path>) {
243        // Slice 3c.final.E.3: clone path for the `Send + 'static`
244        // closure, then route through `mutate_editor_with`.
245        let workspace_root = workspace_root.map(|p| p.to_path_buf());
246        let signals =
247            self.mutate_editor_with(move |e| e.load_persistent_config(workspace_root.as_deref()));
248        for s in signals {
249            self.handle_renderer_signal(s);
250        }
251    }
252
253    /// Load built-in + user snippet packs into the registry at
254    /// startup (built-ins 2026-06-13). Delegates to
255    /// [`lattice_host::dispatch::Editor::load_snippets_at_startup`].
256    /// Called by the runtime right after `load_persistent_config`
257    /// so a fresh editor has its snippet set ready; quiet (logs, no
258    /// echo). Kept out of `App::new` so test `App`s start with an
259    /// empty registry.
260    pub fn load_snippets_at_startup(&mut self) {
261        self.mutate_editor(|e| e.load_snippets_at_startup());
262    }
263
264    /// T.5: open the tutor at lesson `lesson`. Called from
265    /// `lattice-cli` when `--tutor [N]` is passed; fires after
266    /// `load_persistent_config` so user config lands first.
267    pub fn open_tutor(&mut self, lesson: u32) {
268        let signals = self.mutate_editor_with(move |e| e.do_tutor(Some(lesson)));
269        for s in signals {
270            self.handle_renderer_signal(s);
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    /// CB.2 regression guard: `App::new`'s `Arc::get_mut(&mut
280    /// editor.services)` override must succeed. If a future boot change
281    /// clones `editor.services` before this line runs, `Arc::get_mut`
282    /// returns `None` and the `debug_assert!` in `App::new` fires --
283    /// which would turn into a panic on EVERY `App::new`-constructed test
284    /// across the whole TUI suite (App::new is the universal test
285    /// fixture), not just this one. This test pins the invariant
286    /// explicitly rather than relying on that incidental discovery.
287    ///
288    /// The behavioral half asserts *test hermeticity*, not which real
289    /// backend production picks: `clipboard::boot_backend` hands test builds
290    /// the in-memory fake precisely so `cargo test` can't clobber the
291    /// developer's system clipboard (or spray OSC52 at the runner's stdout),
292    /// so what this pins is "the handle a booted App exposes starts empty and
293    /// round-trips" -- true of the fake, false of either real backend. The
294    /// production selection itself is covered in `clipboard::tests`
295    /// (`detect_prefers_osc52_under_ssh`,
296    /// `detect_without_ssh_falls_back_when_feature_off`), which test the pure
297    /// `detect_with` logic without booting an App.
298    #[test]
299    fn new_registers_a_hermetic_clipboard_without_panicking() {
300        let app = App::new(Document::from_text("hello\n"));
301        let clipboard = app
302            .editor
303            .services
304            .get::<lattice_core::ClipboardHandle>()
305            .expect("App::new must leave a ClipboardHandle registered");
306        assert_eq!(
307            clipboard.read(),
308            None,
309            "a freshly booted test App must expose an empty in-memory \
310             clipboard -- a non-None read means the suite is talking to the \
311             OS clipboard"
312        );
313        clipboard.write("cb2-boot-probe".to_string());
314        assert_eq!(
315            clipboard.read(),
316            Some("cb2-boot-probe".to_string()),
317            "the fake round-trips, so yank/paste still exercise the register \
318             layer's clipboard-preferred read"
319        );
320    }
321
322    /// Agent boot-presence guard: `lattice_ai::install` runs in `Editor::boot`'s
323    /// Phase-B list, so a freshly-booted `App` must have both opencode paths
324    /// wired: `:opencode` (the primary ACP buffer conversation, which owns the
325    /// `AiClientHandle` service) and `:opencode-term` (the terminal-TUI spawn,
326    /// kept best-effort). Without them the commands would silently no-op at the
327    /// parser layer. The `:ai-log` picker itself is a later task; this only pins
328    /// that boot wiring landed.
329    #[test]
330    fn new_registers_ai_command_and_service_without_panicking() {
331        let app = App::new(Document::from_text("hello\n"));
332        assert!(
333            app.editor.registry.load().id_by_name("opencode").is_some(),
334            "App::new must register the `:opencode` (ACP conversation) ex-command"
335        );
336        assert!(
337            app.editor
338                .registry
339                .load()
340                .id_by_name("opencode-term")
341                .is_some(),
342            "App::new must register the `:opencode-term` (terminal) ex-command"
343        );
344        app.editor
345            .services
346            .get::<lattice_ai::AiClientHandle>()
347            .expect("App::new must leave an AiClientHandle registered (ACP path)");
348    }
349
350    /// AI-1b T12b: `:ai-log` is registered at boot, and the host
351    /// `do_open_ai_log` empty-session path (no `:opencode` run yet →
352    /// `AiLogger::known_sessions()` empty) echoes a hint instead of
353    /// panicking or opening a stray buffer. Exercises the
354    /// `AiLogger`-service lookup + count branch host-side.
355    #[test]
356    fn ai_log_registered_and_empty_session_path_is_safe() {
357        let mut app = App::new(Document::from_text("hello\n"));
358        assert!(
359            app.editor.registry.load().id_by_name("ai-log").is_some(),
360            "App::new must register the `:ai-log` ex-command"
361        );
362        let before = app.editor.active_pane_buffer_id();
363        // No session started, so known_sessions() is empty → the
364        // 0-session arm echoes a hint and opens nothing.
365        app.do_open_ai_log(None);
366        assert_eq!(
367            app.editor.active_pane_buffer_id(),
368            before,
369            "empty-session `:ai-log` must not switch buffers"
370        );
371    }
372
373    /// `:ai-log <provider>` that matches nothing, while some *other*
374    /// provider's session is live, must name what is running rather than
375    /// telling the user to start an agent that already started. Mirrors
376    /// `open_lsp_picker`'s "(running: ...)" listing.
377    #[test]
378    fn ai_log_unmatched_provider_lists_the_running_sessions() {
379        let mut app = App::new(Document::from_text("hello\n"));
380        let logger = app
381            .editor
382            .services
383            .get::<lattice_ai::AiLogger>()
384            .expect("App::new must leave an AiLogger registered");
385
386        // A record under this key is what makes the session "known".
387        let key = lattice_ai::SessionKey::new(std::sync::Arc::<str>::from("opencode"), 1);
388        logger.log(
389            Some(&key),
390            lattice_ai::AiLogLevel::Info,
391            lattice_ai::AiLogSource::Lifecycle,
392            "session opened",
393        );
394
395        let before = app.editor.active_pane_buffer_id();
396        app.do_open_ai_log(Some("bogus"));
397
398        let message = app
399            .editor
400            .last_message
401            .as_ref()
402            .map(|m| m.text.clone())
403            .unwrap_or_default();
404        assert!(
405            message.contains("opencode:1"),
406            "unmatched provider must list the live sessions, got {message:?}"
407        );
408        assert!(
409            !message.contains(":opencode`)"),
410            "must not tell the user to start an agent that is already running, got {message:?}"
411        );
412        assert_eq!(
413            app.editor.active_pane_buffer_id(),
414            before,
415            "an unmatched `:ai-log` must not switch buffers"
416        );
417    }
418}