Skip to main content

agent_tui/tui/
mod.rs

1//! Chat TUI binary — event loop, terminal setup, module wiring.
2mod transcript;
3
4mod app;
5/// A4: the TUI over `SocketTransport` (`synaps --attach`).
6pub mod attach;
7pub mod client_diet;
8mod clock;
9mod commands;
10mod dispatch;
11mod draw;
12mod driver_client;
13mod effort;
14mod focus;
15mod gamba;
16mod help_find;
17mod helpers;
18mod highlight;
19mod input;
20mod lifecycle;
21mod lightbox;
22mod loop_arms;
23mod markdown;
24mod models;
25mod plugins;
26pub mod quit_guard;
27mod render;
28mod render_model;
29mod render_thread;
30mod run_setup;
31mod session_link;
32mod settings;
33mod sidecar;
34mod signals;
35mod stream_handler;
36/// P16.1: terminal capability facts (env-only detection; inert seam).
37mod termcaps;
38/// Headless test harness — see [`testing::TestHarness`]. Compiled only for
39/// in-crate tests or downstream consumers of the `testing` feature.
40#[cfg(any(test, feature = "testing"))]
41pub mod testing;
42pub(crate) mod text_metrics;
43mod theme;
44mod toast;
45mod view_model;
46mod viewport;
47
48/// Single process-global lock for ALL tests that mutate config-env vars
49/// (`SYNAPS_BASE_DIR`, `HOME`).  Both `migration_tests` (this file) and the
50/// `BASE_DIR_TEST_LOCK` tests in `plugins/actions.rs` must hold this lock for
51/// the duration of any test that sets or reads those vars, so the two groups
52/// can never interleave even when `cargo test` runs them on parallel threads.
53#[cfg(test)]
54pub(crate) static CONFIG_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
55
56pub use run_setup::push_boot_notice;
57
58use app::{App, ChatMessage};
59use commands::CommandAction;
60use draw::{boot_effect, build_render_model, quit_effect};
61use helpers::{apply_setting, fetch_usage, rebuild_display_messages, should_draw};
62use input::InputAction;
63use lifecycle::setup_terminal;
64use render_thread::spawn_render_thread;
65
66use crossterm::event::EventStream;
67use futures::StreamExt;
68use serde_json::json;
69use std::sync::atomic::Ordering;
70use std::time::Instant;
71use synaps_cli::Result;
72
73use agent_engine::session::{EndReason, SessionCommand};
74
75pub async fn run(
76    continue_session: Option<Option<String>>,
77    system: Option<String>,
78    prompt_manifest: Option<std::path::PathBuf>,
79    profile: Option<String>,
80    no_extensions: bool,
81) -> Result<()> {
82    // ── Boot prologue (P12.1: extracted to run_setup.rs) ──
83    // run_setup performs engine-boot unpack, App construction (incl. the
84    // resumed-session branch), channel/handle setup, render-thread spawn,
85    // and tick-throttle init. Behavior byte-identical; the loop below owns
86    // every field by value exactly as it did when they were locals.
87    let ctx = run_setup::run_setup(
88        continue_session,
89        system,
90        prompt_manifest,
91        profile,
92        no_extensions,
93    )
94    .await?;
95    run_loop(ctx).await
96}
97
98/// The event loop + teardown, shared by the in-process boot and the socket
99/// attach (`run_attached`).
100pub(crate) async fn run_loop(ctx: run_setup::RunContext) -> Result<()> {
101    let run_setup::RunContext {
102        mut app,
103        mut link,
104        http,
105        mut prompt_bridge,
106        mode,
107        mut config,
108        registry,
109        keybind_registry,
110        system_prompt_path,
111        render_handle,
112        boot_done,
113        exit_done,
114        term_caps,
115        event_reader,
116        mut shutdown_signal_rx,
117        shutdown_signal_task,
118        secret_prompt_rx,
119        ext_mgr_shared,
120        mut boot_fx_sent,
121        mut exit_fx_sent,
122        mut last_draw,
123    } = ctx;
124    // P16.1+P16.2: terminal capabilities — env detection merged with the
125    // DA1-fenced query burst run inside run_setup() (after raw-mode enable,
126    // BEFORE the EventStream above was created; see run_setup.rs). Still
127    // inert: nothing gates on this until P16.3. The only wiring is this
128    // `--verbose` (debug-level) boot line dumping the negotiated caps.
129    tracing::debug!(caps = %term_caps.summary(), "negotiated terminal capabilities (env + DA1-fenced burst)");
130    // P12.2: Option-wrapped so dispatch::handle_input_action can drop the
131    // reader early (gamba terminal handoff) through a `&mut` without moving
132    // it out of the loop. Always Some outside the dispatch call.
133    let mut event_reader = Some(event_reader);
134    // Live-MXC layer (myx theme): boot the subscriber when configured.
135    loop_arms::boot_myx_live(&mut app);
136    // Throttle state for idle subagent reconcile (~1s cadence in the tick arm).
137    let mut last_subagent_reconcile: Option<std::time::Instant> = None;
138    let mut first_frame_done = false;
139    let mut idle_purge = client_diet::IdlePurge::new(matches!(mode, run_setup::TransportMode::Socket));
140    loop {
141        // Only draw when something actually changed. During streaming, coalesce
142        // redraws to the configured frame budget (`max_fps`, default 60fps =
143        // ~16ms) — deltas and the spinner arrive faster than the eye reads, and
144        // building/publishing the RenderModel per frame is main-thread work
145        // (it re-renders the streaming message's markdown each frame). User
146        // input bypasses the cap via `force_redraw` so scroll/typing stays
147        // instant, and the `!app.streaming` short-circuit renders the final/idle
148        // frame immediately so end-of-turn state never lags. Tune via
149        // `max_fps = 60|144|240|…` in ~/.synaps-cli/config. (Was a hardcoded
150        // 100ms/10fps #131 throttle; 0.3.6 made publish O(viewport) so the cap
151        // could be raised to a real frame rate without burning a core.)
152        let throttle = std::time::Duration::from_millis(1000 / config.max_fps.max(1) as u64);
153        if should_draw(
154            app.needs_redraw,
155            app.force_redraw,
156            app.streaming,
157            last_draw.elapsed(),
158            throttle,
159        ) {
160            // Terminal lives on the render thread — get size via the crossterm
161            // TTY syscall directly (doesn't need the Terminal object).
162            // Skip the frame entirely if the reported size is 0×0 (terminal not
163            // yet ready, or a transient resize event) — publishing a 0×0 model
164            // would produce layout artifacts.
165            let term_size = match crossterm::terminal::size() {
166                Ok((w, h)) if w > 0 && h > 0 => ratatui::layout::Size {
167                    width: w,
168                    height: h,
169                },
170                _ => {
171                    // Terminal not yet ready or transient resize — clear redraw
172                    // flags and back off so we don't busy-spin when the size is
173                    // 0×0 or the syscall fails (#tui-safety fix 1).
174                    app.needs_redraw = false;
175                    app.force_redraw = false;
176                    last_draw = Instant::now();
177                    continue;
178                }
179            };
180            app.needs_redraw = false;
181            app.force_redraw = false;
182            last_draw = Instant::now();
183            let built = build_render_model(
184                &mut view_model::ViewInputs::from_app(&mut app),
185                &**link.view(),
186                &registry,
187                term_size,
188            );
189            if let Some((model, patch)) = built {
190                patch.apply(&mut app);
191                render_handle.publish(model);
192                if !first_frame_done {
193                    first_frame_done = true;
194                    agent_core::core::memstat::ladder("first_frame", &"");
195                }
196            }
197        }
198        idle_purge.observe(app.streaming || app.compacting);
199
200        tokio::select! {
201
202            // ── OS shutdown signals: Ctrl-C from terminal, SIGTERM from systemd/tmux/SSH ──
203            signal = shutdown_signal_rx.recv() => {
204                if let Some(signal) = signal {
205                    tracing::info!(signal = signals::signal_label(signal), "chat UI shutdown signal received");
206                    // All OS signals map to ImmediateExit (see signals.rs).
207                    // The /quit command sends SpawnExitFx to the render thread
208                    // and does NOT go through this path, so removing AnimatedExit
209                    // from signals does not affect interactive quit.
210                    let signals::ShutdownAction::ImmediateExit = signals::shutdown_action(signal);
211                    tracing::info!("immediate exit on {:?}", signal);
212                    // The actor cancels the in-flight turn/compaction on End.
213                    // Fall through to unified bounded-teardown below the loop.
214                    break;
215                }
216            }
217
218            // ── Client diet: idle purge (socket client only; §4.4) ──
219            _ = idle_purge.wait() => {
220                idle_purge.fire();
221            }
222
223            // ── Ping results — fires when a model ping completes ──
224            result = app.ping_rx.recv() => {
225                loop_arms::handle_ping_arm(&mut app, result);
226            }
227
228            // ── Expanded model-list results ──
229            result = app.model_list_rx.recv() => {
230                loop_arms::handle_model_list_arm(&mut app, result);
231            }
232
233            // ── Async extension loader progress ──
234            event = app.extension_loader_rx.recv(), if app.extension_loader_running => {
235                loop_arms::handle_extension_loader_arm(&mut app, link.view(), event, ext_mgr_shared.as_ref()).await;
236            }
237
238            // ── Widget events from background extension notification watchers ──
239            Some(widget_event) = app.widget_rx.recv() => {
240                loop_arms::handle_widget_arm(&mut app, widget_event);
241            }
242
243            // ── Live MXC palettes (myx theme) — UI-thread apply, same path as /theme ──
244            Some(myx_theme) = app.myx_theme_rx.recv() => {
245                loop_arms::handle_myx_theme_arm(&mut app, myx_theme);
246            }
247
248            // ── Sidecar events — multiplexed across all hosted sidecars (Phase 8 8B) ──
249            sidecar_event = self::sidecar::next_event(&mut app.sidecars) => {
250                let (pid, event) = sidecar_event;
251                self::sidecar::handle_event(&mut app, &pid, event);
252                app.request_redraw();
253            }
254
255            // ── Sidecar non-blocking startup completion (G3, in-process only) ──
256            // Socket path: the TUI does not own a SidecarManager (G Q1);
257            // sidecar_starts is always empty under Socket, so this arm pends.
258            (pid, result) = self::sidecar::next_startup(&mut app.sidecar_starts) => {
259                self::sidecar::finish_startup(&mut app, &registry, pid, result);
260                app.request_redraw();
261            }
262
263            // ── Secret-prompt answers from the pane (PromptBridge) → Answer ──
264            Some((prompt_id, value)) = prompt_bridge.answers_rx.recv() => {
265                prompt_bridge.on_local_answer(prompt_id);
266                let _ = link.send(SessionCommand::Answer { prompt_id, value }).await;
267            }
268
269            // ── Tick: animations + spinner (~60fps when active) ──
270            _ = tokio::time::sleep(std::time::Duration::from_millis(16)), if boot_fx_sent || exit_fx_sent || app.streaming || app.compacting || 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() => {
271                if loop_arms::handle_animation_tick(
272                    &mut app, &config, &registry, &render_handle,
273                    &secret_prompt_rx, &boot_done, &exit_done,
274                    &mut boot_fx_sent, exit_fx_sent,
275                    &mut last_subagent_reconcile,
276                )
277                .await
278                {
279                    break;
280                }
281            }
282
283            // ── Input: keyboard, mouse, paste ──
284            maybe_event = event_reader.as_mut().expect("event_reader is always Some outside dispatch").next(), if app.gamba_child.is_none() => {
285                match maybe_event {
286                    Some(Ok(event)) => {
287                        // P7.8: the secret-prompt interception is gone — SecretPrompt
288                        // is a stack-routed pane (`route_secret_prompt`), dispatched
289                        // below by `input::handle_event` on `modal_stack.top()`.
290                        let is_streaming = app.streaming;
291                        // Scope the registry read guard to this block so it is
292                        // provably released before any later `.await`
293                        // (clippy::await_holding_lock) — the guard never spans a
294                        // yield point.
295                        let action = {
296                            let kb_guard = keybind_registry.read().expect("keybind registry poisoned");
297                            input::handle_event(event, &mut app, &**link.view(), is_streaming, &registry, &kb_guard, config.scroll_lines.unwrap_or(3))
298                        };
299                        // Input events (keys, mouse, paste, resize) almost always
300                        // change visible state (cursor, input buffer, scroll) and
301                        // must feel instant — bypass the streaming redraw throttle.
302                        app.request_immediate_redraw();
303                        // P12.2: the ~1,300-line InputAction dispatch match moved
304                        // verbatim to dispatch::handle_input_action (dispatch.rs).
305                        // LoopState lends exactly the loop-locals the match
306                        // touched; Break maps to the outer `break` (unused today,
307                        // honored anyway), Continue to the old fall-through.
308                        let state = dispatch::LoopState {
309                            app: &mut app,
310                            link: &mut link,
311                            http: &http,
312                            config: &mut config,
313                            registry: &registry,
314                            keybind_registry: &keybind_registry,
315                            system_prompt_path: &system_prompt_path,
316                            render_handle: &render_handle,
317                            event_reader: &mut event_reader,
318                            ext_mgr_shared: ext_mgr_shared.as_ref(),
319                            exit_fx_sent: &mut exit_fx_sent,
320                            is_socket: matches!(mode, run_setup::TransportMode::Socket),
321                        };
322                        if dispatch::handle_input_action(action, state).await.is_break() {
323                            break;
324                        }
325                    }
326                    // FIX C (defense in depth): EventStream yields Err or None when
327                    // crossterm detects the PTY is gone. Break cleanly here.
328                    // NOTE: on some kernels crossterm's EPOLL loop can spin without ever
329                    // yielding Err/None on a dead PTY (the confirmed busy-loop bug). The
330                    // render thread's I/O error path is the backstop: it logs the error
331                    // and keeps rendering until the main loop tears down (does NOT break
332                    // the render loop on a single I/O error).
333                    Some(Err(_)) | None => break,
334                }
335            }
336
337            // ── Session envelopes: stream events + every actor notification
338            // (PLAN-phase3 §2.4). `None` = the actor is gone (in-process) or
339            // the socket closed (reconnect flow, A4).
340            maybe_env = link.next_event() => {
341                match maybe_env {
342                    Some(env) => {
343                        if let stream_handler::ArmFlow::Ended = stream_handler::handle_session_event_arm(
344                            env, &mut app, &mut link, &registry, &render_handle,
345                            &mut prompt_bridge, ext_mgr_shared.as_ref(),
346                        ).await {
347                            break;
348                        }
349                    }
350                    None => {
351                        if !run_setup::try_reconnect(&mut app, &mut link, &mode, &render_handle, &registry).await {
352                            break;
353                        }
354                    }
355                }
356            }
357        }
358    }
359
360    // Stop the live-MXC subscriber FIRST — no background task writes past here.
361    loop_arms::abort_myx_live(&mut app);
362
363    // ── PART 2: Bounded teardown — the actor saves, fires on_session_end
364    // and flushes observability under its own budgets (`SessionActor::
365    // finish`); we ask for it and wait for `Ended` within the sum of those
366    // budgets plus margin (`SESSION_END_TIMEOUT_SECS`). The channel closing
367    // cleanly before `Ended` is observed is a clean exit too (exit 0); only
368    // an actor still running past its whole budget is an emergency exit.
369    // Over the socket the session lives on: Detach.
370    let teardown = std::time::Duration::from_secs(signals::SESSION_END_TIMEOUT_SECS);
371    if !link.is_closed() {
372        let cmd = match mode {
373            run_setup::TransportMode::Local { .. } => SessionCommand::End {
374                reason: EndReason::ClientQuit,
375            },
376            run_setup::TransportMode::Socket => SessionCommand::Detach {
377                client: link.client_id(),
378            },
379        };
380        let _ = link.send(cmd).await;
381        agent_core::core::memstat::ladder("detach", &"");
382        if let run_setup::TransportMode::Local { .. } = mode {
383            match tokio::time::timeout(teardown, link.wait_ended()).await {
384                Ok(true) => tracing::debug!("clean teardown completed"),
385                Ok(false) => {
386                    tracing::debug!("session channel closed before Ended — clean exit")
387                }
388                Err(_elapsed) => {
389                    tracing::warn!(
390                        budget_secs = signals::SESSION_END_TIMEOUT_SECS,
391                        "session end timed out — data may be incomplete"
392                    );
393                    lifecycle::emergency_exit();
394                }
395            }
396        }
397    }
398
399    // Let extension shutdown continue in the background; exit should not hang on
400    // extension post/session-end cleanup or slow child-process teardown.
401    let _extension_shutdown = ext_mgr_shared.as_ref().map(|m| {
402        synaps_cli::extensions::manager::ExtensionManager::shutdown_all_detached(
403            std::sync::Arc::clone(m),
404        )
405    });
406    // Stop the signal-listener thread (signal-hook handle, not a JoinHandle).
407    shutdown_signal_task.close();
408
409    // ── Render-thread teardown ───────────────────────────────────────────────
410    //
411    // The render thread owns the Terminal.  We send it a Teardown command and
412    // wait for the ack within the combined SAVE + HOOKS budget already spent
413    // above.  If the ack doesn't arrive the thread is wedged (dead PTY); we
414    // skip the join and let process exit reap it — see RenderHandle::teardown.
415    // This self-bounding teardown replaced the old signal watchdog (#116).
416    //
417    // The render thread's do_teardown() calls emergency_teardown_terminal()
418    // (disable_raw_mode + LeaveAlternateScreen + etc.) and show_cursor(), then
419    // sends the ack and exits its loop.  The Terminal is dropped when the
420    // thread exits — that's safe because crossterm teardown was already done.
421    let teardown_budget = std::time::Duration::from_secs(
422        signals::TEARDOWN_TIMEOUT_SECS.saturating_sub(signals::SAVE_TIMEOUT_SECS),
423    )
424    .max(std::time::Duration::from_secs(2));
425    let acked = render_handle.teardown(teardown_budget);
426    if !acked {
427        tracing::warn!("render thread did not ack teardown within budget — watchdog is backstop");
428        // emergency_teardown_terminal is a no-op if the terminal is already
429        // restored, so calling it here is safe even if the render thread did
430        // eventually finish teardown after the timeout.
431        lifecycle::emergency_teardown_terminal();
432    }
433
434    // ── F9: daemon-lost exit — stderr message + non-zero code ──
435    if let Some(lost) = app.daemon_lost {
436        eprintln!(
437            "synaps: lost the daemon (pid {}) and could not reconnect within {} s \
438             — session {}; resume with synaps --attach {} / --continue {}",
439            lost.pid, lost.budget_secs, lost.session_id, lost.session_id, lost.session_id,
440        );
441        std::process::exit(agent_engine::daemon::EXIT_DAEMON_LOST);
442    }
443
444    Ok(())
445}