Skip to main content

agent_tui/tui/
mod.rs

1//! Chat TUI binary — event loop, terminal setup, module wiring.
2mod transcript;
3
4mod app;
5mod clock;
6mod commands;
7mod dispatch;
8mod draw;
9mod effort;
10mod focus;
11mod gamba;
12mod help_find;
13mod helpers;
14mod highlight;
15mod input;
16mod lifecycle;
17mod lightbox;
18mod loop_arms;
19mod markdown;
20mod models;
21mod plugins;
22mod render;
23mod render_model;
24mod render_thread;
25mod run_setup;
26mod settings;
27mod sidecar;
28mod signals;
29mod stream_handler;
30/// P16.1: terminal capability facts (env-only detection; inert seam).
31mod termcaps;
32/// Headless test harness — see [`testing::TestHarness`]. Compiled only for
33/// in-crate tests or downstream consumers of the `testing` feature.
34#[cfg(any(test, feature = "testing"))]
35pub mod testing;
36pub(crate) mod text_metrics;
37mod theme;
38mod toast;
39mod view_model;
40mod viewport;
41
42/// Single process-global lock for ALL tests that mutate config-env vars
43/// (`SYNAPS_BASE_DIR`, `HOME`).  Both `migration_tests` (this file) and the
44/// `BASE_DIR_TEST_LOCK` tests in `plugins/actions.rs` must hold this lock for
45/// the duration of any test that sets or reads those vars, so the two groups
46/// can never interleave even when `cargo test` runs them on parallel threads.
47#[cfg(test)]
48pub(crate) static CONFIG_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
49
50use app::{App, ChatMessage, THINKING_PLACEHOLDER};
51use commands::CommandAction;
52use draw::{boot_effect, build_render_model, quit_effect};
53use helpers::{apply_setting, fetch_usage, rebuild_display_messages, should_draw};
54use input::InputAction;
55use lifecycle::setup_terminal;
56use render_thread::spawn_render_thread;
57
58use crossterm::event::EventStream;
59use futures::StreamExt;
60use serde_json::json;
61use std::sync::atomic::Ordering;
62use std::time::Instant;
63use synaps_cli::core::session_index::SessionIndexRecord;
64use synaps_cli::runtime::compaction::compact_conversation;
65use synaps_cli::{CancellationToken, Result, Runtime};
66
67pub async fn run(
68    continue_session: Option<Option<String>>,
69    system: Option<String>,
70    prompt_manifest: Option<std::path::PathBuf>,
71    profile: Option<String>,
72    no_extensions: bool,
73) -> Result<()> {
74    // ── Boot prologue (P12.1: extracted to run_setup.rs) ──
75    // run_setup performs engine-boot unpack, App construction (incl. the
76    // resumed-session branch), channel/handle setup, render-thread spawn,
77    // and tick-throttle init. Behavior byte-identical; the loop below owns
78    // every field by value exactly as it did when they were locals.
79    let run_setup::RunContext {
80        mut app,
81        mut runtime,
82        mut config,
83        registry,
84        keybind_registry,
85        system_prompt_path,
86        render_handle,
87        boot_done,
88        exit_done,
89        term_caps,
90        event_reader,
91        mut shutdown_signal_rx,
92        shutdown_signal_task,
93        mut stream,
94        secret_prompt_handle,
95        secret_prompt_rx,
96        mut cancel_token,
97        mut steer_tx,
98        background,
99        ext_mgr_shared,
100        mut boot_fx_sent,
101        mut exit_fx_sent,
102        mut last_draw,
103    } = run_setup::run_setup(
104        continue_session,
105        system,
106        prompt_manifest,
107        profile,
108        no_extensions,
109    )
110    .await?;
111    // P16.1+P16.2: terminal capabilities — env detection merged with the
112    // DA1-fenced query burst run inside run_setup() (after raw-mode enable,
113    // BEFORE the EventStream above was created; see run_setup.rs). Still
114    // inert: nothing gates on this until P16.3. The only wiring is this
115    // `--verbose` (debug-level) boot line dumping the negotiated caps.
116    tracing::debug!(caps = %term_caps.summary(), "negotiated terminal capabilities (env + DA1-fenced burst)");
117    // P12.2: Option-wrapped so dispatch::handle_input_action can drop the
118    // reader early (gamba terminal handoff) through a `&mut` without moving
119    // it out of the loop. Always Some outside the dispatch call.
120    let mut event_reader = Some(event_reader);
121    // Live-MXC layer (myx theme): boot the subscriber when configured.
122    loop_arms::boot_myx_live(&mut app);
123    // Throttle state for idle subagent reconcile (~1s cadence in the tick arm).
124    let mut last_subagent_reconcile: Option<std::time::Instant> = None;
125    loop {
126        // Only draw when something actually changed. During streaming, coalesce
127        // redraws to the configured frame budget (`max_fps`, default 60fps =
128        // ~16ms) — deltas and the spinner arrive faster than the eye reads, and
129        // building/publishing the RenderModel per frame is main-thread work
130        // (it re-renders the streaming message's markdown each frame). User
131        // input bypasses the cap via `force_redraw` so scroll/typing stays
132        // instant, and the `!app.streaming` short-circuit renders the final/idle
133        // frame immediately so end-of-turn state never lags. Tune via
134        // `max_fps = 60|144|240|…` in ~/.synaps-cli/config. (Was a hardcoded
135        // 100ms/10fps #131 throttle; 0.3.6 made publish O(viewport) so the cap
136        // could be raised to a real frame rate without burning a core.)
137        let throttle = std::time::Duration::from_millis(1000 / config.max_fps.max(1) as u64);
138        if should_draw(
139            app.needs_redraw,
140            app.force_redraw,
141            app.streaming,
142            last_draw.elapsed(),
143            throttle,
144        ) {
145            // Terminal lives on the render thread — get size via the crossterm
146            // TTY syscall directly (doesn't need the Terminal object).
147            // Skip the frame entirely if the reported size is 0×0 (terminal not
148            // yet ready, or a transient resize event) — publishing a 0×0 model
149            // would produce layout artifacts.
150            let term_size = match crossterm::terminal::size() {
151                Ok((w, h)) if w > 0 && h > 0 => ratatui::layout::Size {
152                    width: w,
153                    height: h,
154                },
155                _ => {
156                    // Terminal not yet ready or transient resize — clear redraw
157                    // flags and back off so we don't busy-spin when the size is
158                    // 0×0 or the syscall fails (#tui-safety fix 1).
159                    app.needs_redraw = false;
160                    app.force_redraw = false;
161                    last_draw = Instant::now();
162                    continue;
163                }
164            };
165            app.needs_redraw = false;
166            app.force_redraw = false;
167            last_draw = Instant::now();
168            let built = build_render_model(
169                &mut view_model::ViewInputs::from_app(&mut app),
170                &runtime,
171                &registry,
172                term_size,
173            );
174            if let Some((model, patch)) = built {
175                patch.apply(&mut app);
176                render_handle.publish(model);
177            }
178        }
179
180        tokio::select! {
181
182            // ── OS shutdown signals: Ctrl-C from terminal, SIGTERM from systemd/tmux/SSH ──
183            signal = shutdown_signal_rx.recv() => {
184                if let Some(signal) = signal {
185                    tracing::info!(signal = signals::signal_label(signal), "chat UI shutdown signal received");
186                    // All OS signals map to ImmediateExit (see signals.rs).
187                    // The /quit command sends SpawnExitFx to the render thread
188                    // and does NOT go through this path, so removing AnimatedExit
189                    // from signals does not affect interactive quit.
190                    let signals::ShutdownAction::ImmediateExit = signals::shutdown_action(signal);
191                    tracing::info!("immediate exit on {:?}", signal);
192                    // Cancel any in-flight stream so the tool/subagent is not
193                    // orphaned for the full watchdog window.
194                    if let Some(ref ct) = cancel_token { ct.cancel(); }
195                    // Abort any in-flight compaction so it doesn't hold state
196                    // open past the teardown budget.
197                    if let Some(ref h) = app.compact_task { h.abort(); }
198                    // Fall through to unified bounded-teardown below the loop.
199                    break;
200                }
201            }
202
203            // ── Ping results — fires when a model ping completes ──
204            result = app.ping_rx.recv() => {
205                loop_arms::handle_ping_arm(&mut app, result);
206            }
207
208            // ── Expanded model-list results ──
209            result = app.model_list_rx.recv() => {
210                loop_arms::handle_model_list_arm(&mut app, result);
211            }
212
213            // ── Async extension loader progress ──
214            event = app.extension_loader_rx.recv(), if app.extension_loader_running => {
215                loop_arms::handle_extension_loader_arm(&mut app, &runtime, event, &ext_mgr_shared).await;
216            }
217
218            // ── Widget events from background extension notification watchers ──
219            Some(widget_event) = app.widget_rx.recv() => {
220                loop_arms::handle_widget_arm(&mut app, widget_event);
221            }
222
223            // ── Live MXC palettes (myx theme) — UI-thread apply, same path as /theme ──
224            Some(myx_theme) = app.myx_theme_rx.recv() => {
225                loop_arms::handle_myx_theme_arm(&mut app, myx_theme);
226            }
227
228            // ── Sidecar events — multiplexed across all hosted sidecars (Phase 8 8B) ──
229            sidecar_event = async {
230                if app.sidecars.is_empty() {
231                    let _: () = std::future::pending().await;
232                    unreachable!()
233                } else {
234                    // Collect (plugin_id, &mut manager) and race them.
235                    let mut futures = Vec::with_capacity(app.sidecars.len());
236                    for (pid, v) in app.sidecars.iter_mut() {
237                        let pid = pid.clone();
238                        futures.push(Box::pin(async move {
239                            let ev = v.manager.next_event().await;
240                            (pid, ev)
241                        }));
242                    }
243                    let ((pid, ev), _, _) = futures::future::select_all(futures).await;
244                    (pid, ev)
245                }
246            } => {
247                let (pid, sidecar_event) = sidecar_event;
248                if let Some(event) = sidecar_event {
249                    self::sidecar::handle_event(&mut app, &pid, event);
250                    app.request_redraw();
251                }
252            }
253
254            // ── Event bus wake — fires instantly when an event is pushed to the queue.
255            // P12.4: arm body moved verbatim to stream_handler::handle_event_queue_arm.
256            _ = runtime.event_queue().notified() => {
257                stream_handler::handle_event_queue_arm(
258                    &mut app, &runtime, &secret_prompt_handle,
259                    &mut stream, &mut cancel_token, &mut steer_tx,
260                ).await;
261            }
262
263            // ── Tick: animations + spinner (~60fps when active) ──
264            _ = tokio::time::sleep(std::time::Duration::from_millis(16)), if boot_fx_sent || exit_fx_sent || app.streaming || app.compact_task.is_some() || app.transcript.is_empty() || app.logo_dismiss_t.is_some() || app.logo_build_t.is_some() || app.gamba_child.is_some() || app.secret_prompts.is_active() || !app.toasts.is_empty() || app.plugins.as_ref().is_some_and(|p| p.is_install_active()) || !app.subagents.is_empty() || app.theme_transition.is_some() => {
265                if loop_arms::handle_animation_tick(
266                    &mut app, &runtime, &config, &registry, &render_handle,
267                    &secret_prompt_rx, &boot_done, &exit_done,
268                    &mut boot_fx_sent, exit_fx_sent,
269                    &mut last_subagent_reconcile,
270                )
271                .await
272                {
273                    break;
274                }
275            }
276
277            // ── Input: keyboard, mouse, paste ──
278            maybe_event = event_reader.as_mut().expect("event_reader is always Some outside dispatch").next(), if app.gamba_child.is_none() => {
279                match maybe_event {
280                    Some(Ok(event)) => {
281                        // P7.8: the secret-prompt interception is gone — SecretPrompt
282                        // is a stack-routed pane (`route_secret_prompt`), dispatched
283                        // below by `input::handle_event` on `modal_stack.top()`.
284                        let is_streaming = app.streaming;
285                        // Scope the registry read guard to this block so it is
286                        // provably released before any later `.await`
287                        // (clippy::await_holding_lock) — the guard never spans a
288                        // yield point.
289                        let action = {
290                            let kb_guard = keybind_registry.read().expect("keybind registry poisoned");
291                            input::handle_event(event, &mut app, &runtime, is_streaming, &registry, &kb_guard, config.scroll_lines.unwrap_or(3))
292                        };
293                        // Input events (keys, mouse, paste, resize) almost always
294                        // change visible state (cursor, input buffer, scroll) and
295                        // must feel instant — bypass the streaming redraw throttle.
296                        app.request_immediate_redraw();
297                        // P12.2: the ~1,300-line InputAction dispatch match moved
298                        // verbatim to dispatch::handle_input_action (dispatch.rs).
299                        // LoopState lends exactly the loop-locals the match
300                        // touched; Break maps to the outer `break` (unused today,
301                        // honored anyway), Continue to the old fall-through.
302                        let state = dispatch::LoopState {
303                            app: &mut app,
304                            runtime: &mut runtime,
305                            config: &mut config,
306                            registry: &registry,
307                            keybind_registry: &keybind_registry,
308                            system_prompt_path: &system_prompt_path,
309                            render_handle: &render_handle,
310                            event_reader: &mut event_reader,
311                            stream: &mut stream,
312                            secret_prompt_handle: &secret_prompt_handle,
313                            cancel_token: &mut cancel_token,
314                            steer_tx: &mut steer_tx,
315                            ext_mgr_shared: &ext_mgr_shared,
316                            exit_fx_sent: &mut exit_fx_sent,
317                        };
318                        if dispatch::handle_input_action(action, state).await.is_break() {
319                            break;
320                        }
321                    }
322                    // FIX C (defense in depth): EventStream yields Err or None when
323                    // crossterm detects the PTY is gone. Break cleanly here.
324                    // NOTE: on some kernels crossterm's EPOLL loop can spin without ever
325                    // yielding Err/None on a dead PTY (the confirmed busy-loop bug). The
326                    // render thread's I/O error path is the backstop: it logs the error
327                    // and keeps rendering until the main loop tears down (does NOT break
328                    // the render loop on a single I/O error).
329                    Some(Err(_)) | None => break,
330                }
331            }
332
333            // ── Stream events from runtime. P12.4: the polling future stays
334            // inline (it borrows `stream`); the arm body moved verbatim to
335            // stream_handler::handle_stream_arm — delta/tool_use/done/abort
336            // lifecycle is preserved exactly (hot path).
337            maybe_event = async {
338                if let Some(ref mut s) = stream {
339                    s.next().await
340                } else {
341                    std::future::pending().await
342                }
343            } => {
344                stream_handler::handle_stream_arm(
345                    maybe_event, &mut app, &runtime, &registry, &render_handle,
346                    &secret_prompt_handle, &mut stream, &mut cancel_token, &mut steer_tx,
347                ).await;
348            }
349        }
350    }
351
352    // Stop the live-MXC subscriber FIRST — no background task writes past here.
353    loop_arms::abort_myx_live(&mut app);
354
355    // ── PART 2: Bounded teardown — two sequential budgets.
356    //
357    // All timing constants are defined in signals.rs (single source of truth):
358    //   SAVE_TIMEOUT_SECS  — session save + index record (data safety first)
359    //   HOOKS_TIMEOUT_SECS — on_session_end hook emit (concurrent, fail-open)
360    //   TEARDOWN_TIMEOUT_SECS = SAVE_TIMEOUT_SECS + HOOKS_TIMEOUT_SECS
361    //
362    // Session save ALWAYS runs first in its own timeout so slow extension
363    // handlers cannot starve it.  Even if the hook budget is exhausted, the
364    // session data on disk is already safe before hooks are attempted.
365    {
366        let session_id = app.session.id.clone();
367        let api_messages = app.api_messages.clone();
368
369        // ── STEP 1: Save session data — own bounded timeout, highest priority ──
370        let save_fut = async {
371            app.save_session().await;
372
373            let mut index_record = SessionIndexRecord::end(&session_id);
374            index_record.turns = Some(api_messages.len());
375            if let Err(err) = synaps_cli::core::session_index::append_record(&index_record) {
376                tracing::warn!("failed to append session end index record: {}", err);
377            }
378        };
379
380        match tokio::time::timeout(
381            std::time::Duration::from_secs(signals::SAVE_TIMEOUT_SECS),
382            save_fut,
383        )
384        .await
385        {
386            Ok(()) => tracing::debug!("session save completed"),
387            Err(_elapsed) => {
388                tracing::warn!(
389                    budget_secs = signals::SAVE_TIMEOUT_SECS,
390                    "session save timed out — data may be incomplete"
391                );
392                // Task 11: bounded observability flush precedes the emergency exit.
393                lifecycle::emergency_flush_and_exit(&runtime).await;
394            }
395        }
396
397        // ── STEP 2: Fire on_session_end hook — own bounded timeout, after save ──
398        //
399        // emit_concurrent() dispatches all on_session_end handlers simultaneously
400        // under one shared timeout window instead of N×5 s serial.  This is safe
401        // because on_session_end only allows `Continue` results — handlers are
402        // independent fire-and-forget notification calls (deck, d20, jawz-widget,
403        // synaps-tasks each write to their own stores; no ordering dependency).
404        //
405        // Ordering-safety evidence: HookKind::OnSessionEnd::allowed_action_names()
406        // returns &["continue"] exclusively; allows_result() permits only Continue;
407        // emit_concurrent() merges injections (N/A here) and treats timeouts as
408        // continue (fail-open).  Serial ordering cannot matter when the return
409        // value is always Continue and handlers touch disjoint state.
410        let transcript = Some(api_messages);
411        let hook_event = synaps_cli::extensions::hooks::events::HookEvent::on_session_end(
412            &session_id,
413            transcript,
414        );
415        match tokio::time::timeout(
416            std::time::Duration::from_secs(signals::HOOKS_TIMEOUT_SECS),
417            runtime.hook_bus().emit_concurrent(&hook_event),
418        )
419        .await
420        {
421            Ok(_) => tracing::debug!("on_session_end hooks completed"),
422            Err(_elapsed) => {
423                tracing::warn!(
424                    budget_secs = signals::HOOKS_TIMEOUT_SECS,
425                    "on_session_end hooks timed out — extensions may not have flushed"
426                );
427                // Session is already saved above — no data loss here.
428                // Fall through to normal teardown.
429            }
430        }
431
432        // STEP 3 (Task 11): bounded observability flush after save + hooks —
433        // Off/no-fsync/timeout semantics on lifecycle::flush_observability.
434        lifecycle::flush_observability(&runtime).await;
435        tracing::debug!("clean teardown completed");
436    }
437
438    // Let extension shutdown continue in the background; exit should not hang on
439    // extension post/session-end cleanup or slow child-process teardown.
440    let _extension_shutdown =
441        synaps_cli::extensions::manager::ExtensionManager::shutdown_all_detached(
442            std::sync::Arc::clone(&ext_mgr_shared),
443        );
444    // Stop the signal-listener thread (signal-hook handle, not a JoinHandle).
445    shutdown_signal_task.close();
446
447    // Shut down background tasks (inbox watcher, socket, session registry)
448    background.shutdown();
449
450    // ── Render-thread teardown ───────────────────────────────────────────────
451    //
452    // The render thread owns the Terminal.  We send it a Teardown command and
453    // wait for the ack within the combined SAVE + HOOKS budget already spent
454    // above.  If the ack doesn't arrive the thread is wedged (dead PTY); we
455    // skip the join and let process exit reap it — see RenderHandle::teardown.
456    // This self-bounding teardown replaced the old signal watchdog (#116).
457    //
458    // The render thread's do_teardown() calls emergency_teardown_terminal()
459    // (disable_raw_mode + LeaveAlternateScreen + etc.) and show_cursor(), then
460    // sends the ack and exits its loop.  The Terminal is dropped when the
461    // thread exits — that's safe because crossterm teardown was already done.
462    let teardown_budget = std::time::Duration::from_secs(
463        signals::TEARDOWN_TIMEOUT_SECS.saturating_sub(signals::SAVE_TIMEOUT_SECS),
464    )
465    .max(std::time::Duration::from_secs(2));
466    let acked = render_handle.teardown(teardown_budget);
467    if !acked {
468        tracing::warn!("render thread did not ack teardown within budget — watchdog is backstop");
469        // emergency_teardown_terminal is a no-op if the terminal is already
470        // restored, so calling it here is safe even if the render thread did
471        // eventually finish teardown after the timeout.
472        lifecycle::emergency_teardown_terminal();
473    }
474
475    Ok(())
476}