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}