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