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