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 ®istry,
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, ®istry, &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, ®istry, &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: ®istry,
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, ®istry, &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}