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