Skip to main content

agent_tui/tui/
run_setup.rs

1//! P12.1: boot/setup prologue extracted from `run()` — PURE CODE MOTION.
2//!
3//! The body of [`run_setup`] below is byte-identical to `run()`'s former
4//! prologue (previously `mod.rs` lines 68-207): engine boot unpack, `App`
5//! construction (incl. the resumed-session branch), channel/handle setup,
6//! render-thread spawn, and the tick-throttle state init. `run()` now calls
7//! this, destructures the returned [`RunContext`], and enters the (unchanged)
8//! `tokio::select!` loop. No logic changed.
9
10use super::*;
11
12use std::sync::Arc;
13use std::time::Instant;
14use synaps_cli::Result;
15
16use agent_engine::host::{EngineHost, HostOpts};
17use agent_engine::session::{
18    AttachMode, AttachSnapshot, ClientKind, ClientMeta, ClientTransport, CompactionPolicyWire,
19    LocalTransport, SessionConfig,
20};
21use helpers::apply_display_tail;
22use session_link::{PromptBridge, SessionLink};
23
24/// How this TUI reaches its session (PLAN-phase3 §3.2).
25pub(crate) enum TransportMode {
26    /// In-process: the `EngineHost` + `SessionActor` live in this process
27    /// (the host is kept alive for the loop's lifetime).
28    Local {
29        #[allow(dead_code)]
30        host: Arc<EngineHost>,
31    },
32    /// Over the daemon socket (A4): no host, no runtime, no extension host.
33    Socket,
34}
35
36/// Client-local HTTP client, built on first use (P4-0). The in-process TUI
37/// seeds it with the host's client (`LazyHttp::from`), so nothing changes
38/// there; the socket client (`--attach`) pays for reqwest + rustls + the
39/// native root store only when `/models`, `/settings` or a catalog expand
40/// actually asks (`SYNAPS_CLIENT_HTTP=eager` builds it at boot — bisect aid).
41pub(crate) struct LazyHttp(std::cell::OnceCell<reqwest::Client>);
42
43use agent_core::core::memstat::ladder as ladder_stage;
44
45impl LazyHttp {
46    /// Empty; the first `get()` builds via `build_host_http_client`.
47    pub(crate) fn new() -> Self {
48        Self(std::cell::OnceCell::new())
49    }
50
51    /// The client, building it on first use (emits the `http` ladder stage).
52    pub(crate) fn get(&self) -> Result<&reqwest::Client> {
53        if let Some(c) = self.0.get() {
54            return Ok(c);
55        }
56        let t0 = Instant::now();
57        let client = agent_engine::runtime::build_host_http_client()?;
58        agent_core::core::memstat::ladder(
59            "http",
60            &format_args!("build_ms={}", t0.elapsed().as_millis()),
61        );
62        Ok(self.0.get_or_init(|| client))
63    }
64
65    /// Already built?
66    #[allow(dead_code)]
67    pub(crate) fn is_built(&self) -> bool {
68        self.0.get().is_some()
69    }
70}
71
72impl From<reqwest::Client> for LazyHttp {
73    fn from(client: reqwest::Client) -> Self {
74        Self(std::cell::OnceCell::from(client))
75    }
76}
77
78/// Everything `run()`'s event loop and teardown consume from the boot
79/// prologue. Fields are handed back by value so `run()` owns them exactly as
80/// it did when they were locals — the loop's `&mut` borrows are unchanged.
81pub(crate) struct RunContext {
82    pub app: App,
83    /// The session, behind a transport (in-process `LocalTransport` today;
84    /// `SocketTransport` for `--attach`).
85    pub link: SessionLink,
86    /// Client-local HTTP client for catalog/model-list fetches (the same
87    /// builder the host uses; lazy on the socket path).
88    pub http: LazyHttp,
89    pub prompt_bridge: PromptBridge,
90    pub mode: TransportMode,
91    pub config: synaps_cli::SynapsConfig,
92    pub registry: std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
93    pub keybind_registry:
94        std::sync::Arc<std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>>,
95    pub system_prompt_path: std::path::PathBuf,
96    pub render_handle: render_thread::RenderHandle,
97    pub boot_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
98    pub exit_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
99    /// P16.2: negotiated terminal capabilities — env detection merged with
100    /// the DA1-fenced query burst (or env-only if the burst timed out).
101    pub term_caps: termcaps::TermCaps,
102    pub event_reader: EventStream,
103    pub shutdown_signal_rx: tokio::sync::mpsc::UnboundedReceiver<signals::ShutdownSignal>,
104    pub shutdown_signal_task: signals::SignalHandle,
105    pub secret_prompt_rx: std::sync::Arc<
106        std::sync::Mutex<
107            tokio::sync::mpsc::UnboundedReceiver<synaps_cli::tools::SecretPromptRequest>,
108        >,
109    >,
110    /// In-process only (`Some` under `TransportMode::Local`): the host's
111    /// extension manager, for `/extensions` and interactive plugin commands.
112    pub ext_mgr_shared: Option<
113        std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
114    >,
115    pub boot_fx_sent: bool,
116    pub exit_fx_sent: bool,
117    pub last_draw: Instant,
118}
119
120/// Boot prologue for [`super::run`]. Same inputs / error type as `run()`.
121static BOOT_NOTICES: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
122
123/// Queue a `SystemNotice` shown at the top of the in-process TUI (called
124/// before the runtime exists — e.g. `--attach` falling back after the daemon
125/// refused to start).
126pub fn push_boot_notice(text: impl Into<String>) {
127    BOOT_NOTICES.lock().unwrap_or_else(|e| e.into_inner()).push(text.into());
128}
129
130pub(crate) fn take_boot_notices() -> Vec<String> {
131    std::mem::take(&mut *BOOT_NOTICES.lock().unwrap_or_else(|e| e.into_inner()))
132}
133
134pub(crate) async fn run_setup(
135    continue_session: Option<Option<String>>,
136    system: Option<String>,
137    prompt_manifest: Option<std::path::PathBuf>,
138    profile: Option<String>,
139    no_extensions: bool,
140) -> Result<RunContext> {
141    // ── Engine boot: host + one session on the actor, attached in-process ──
142    let host = EngineHost::boot_and_install(HostOpts {
143        profile,
144        no_extensions,
145    })
146    .await?;
147    let handle = host
148        .create_session(SessionConfig {
149            continue_session,
150            system,
151            prompt_manifest,
152            cwd: None,
153            env: None,
154            env_stripped: Vec::new(),
155            auto_approve_confirms: false,
156            model_override: None,
157            persist: true,
158            auto_compact: false,
159            compaction_policy: CompactionPolicyWire::LinkedSuccessor,
160            await_extensions: false,
161            keep_warm: false,
162            name: None,
163            max_session_cost: None,
164        })
165        .await?;
166    let (transport, snapshot) =
167        LocalTransport::attach_with(handle, ClientMeta::new(ClientKind::Tui), AttachMode::Mirror)
168            .await
169            .map_err(|e| synaps_cli::RuntimeError::Config(format!("attach: {e}")))?;
170
171    let config: synaps_cli::SynapsConfig = (**host.config()).clone();
172    let registry = Arc::clone(host.command_registry());
173    let keybind_registry = Arc::clone(host.keybind_registry());
174    let mcp_server_count = host.mcp_server_count();
175    let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
176    let http = LazyHttp::from(host.parts().client.clone());
177    let ext_mgr_shared = Arc::clone(host.ext_manager());
178
179    let mut app = app_from_snapshot(&snapshot);
180    app.keybinds = Some(keybind_registry.clone());
181
182    // Notices queued before boot (daemon auto-spawn fallback) go first.
183    for n in take_boot_notices() {
184        app.push_msg(ChatMessage::System(n));
185    }
186    // Surface config parse warnings once at startup (unknown keys, bad values).
187    for w in &config.warnings {
188        app.push_msg(ChatMessage::System(format!("⚠ config: {}", w)));
189    }
190
191    // First-run guidance: no Anthropic credentials and no provider keys means
192    // the first message will fail — tell the user up front instead.
193    {
194        // Capability queries via the broker boundary — no credential file or
195        // credential env reads here, and answers are booleans only.
196        let has_anthropic = synaps_cli::auth::broker::anthropic_credential_available();
197        let has_provider_key =
198            !synaps_cli::auth::broker::configured_static_provider_keys().is_empty();
199        if !has_anthropic && !has_provider_key {
200            app.push_msg(ChatMessage::System(
201                "👋 No credentials found. To get started:\n   • `synaps login` — sign in with Claude Pro/Max (OAuth)\n   • or `synaps login --provider <name>` — store a provider API key with the credential broker (groq, openrouter, …), then pick with /model".to_string(),
202            ));
203        }
204    }
205
206    if mcp_server_count > 0 {
207        tracing::info!(
208            "{} MCP servers available (use connect_mcp_server to activate)",
209            mcp_server_count
210        );
211    }
212
213    let mut ctx = finish_setup(
214        app,
215        Box::new(transport),
216        http,
217        TransportMode::Local { host },
218        config,
219        registry,
220        keybind_registry,
221        system_prompt_path,
222        Some(ext_mgr_shared),
223    )
224    .await?;
225
226    // Legacy sidecar key migration
227    loop_arms::migrate_sidecar_toggle_key_to_claimed_plugins(&ctx.registry.lifecycle_claims());
228
229    if !no_extensions {
230        let session_id_for_hook = ctx.app.session.id.clone();
231        ctx.app.extension_loader_running = true;
232        ctx.app.toasts.upsert(
233            toast::Toast::new("extension-loader", "Discovering extensions…")
234                .titled("Extensions")
235                .at(toast::ToastPosition::TOP_CENTER)
236                .ttl(None),
237        );
238        // on_session_start is emitted by the extension loader once
239        // subscribers exist — same moment, same bus as before the port.
240        // (When the actor's `await_extensions=false` waiter lands (B1) this
241        // becomes `None` — the actor emits it.)
242        synaps_cli::extensions::loader::spawn_discover_and_load(
243            std::sync::Arc::clone(ctx.ext_mgr_shared.as_ref().expect("local mode")),
244            ctx.app.extension_loader_tx.clone(),
245            Some(session_id_for_hook),
246        );
247    }
248    Ok(ctx)
249}
250
251/// Build `App` from the attach snapshot — the same lines and texts the
252/// `EngineBoot`-based construction produced (`continued` branch included).
253pub(crate) fn app_from_snapshot(snapshot: &AttachSnapshot) -> App {
254    let conv = &snapshot.conversation;
255    let session = app::session_from_header(&conv.header);
256    // F2: apply conversation + display unconditionally when there is history.
257    // `continued` only gates the "resumed session …" notice — a fresh session
258    // re-attached after parking has history but `continued == false`.
259    let has_history = conv.messages_len > 0
260        || !conv.api_messages.is_empty()
261        || snapshot
262            .display_tail
263            .as_ref()
264            .is_some_and(|t| !t.items.is_empty());
265    let mut app = App::new_with_clock(session, clock::TuiClock::real());
266    if has_history {
267        app.apply_conversation(conv);
268        rebuild_display_from_snapshot(&mut app, snapshot);
269    }
270    if snapshot.meta.continued {
271        app.push_msg(ChatMessage::System(format!(
272            "resumed session {}",
273            conv.header.id
274        )));
275        if let Some(ref info) = snapshot.meta.continue_info {
276            if let Some(ref via) = info.resolved_via {
277                app.push_msg(ChatMessage::System(format!(
278                    "  ↳ resolved via {} '{}'",
279                    via, info.query
280                )));
281            }
282            if let Some(ref notice) = info.compaction_notice {
283                app.push_msg(ChatMessage::System(format!("  ↳ {}", notice)));
284            }
285        }
286    }
287    if app.abort_context.is_some() {
288        app.push_msg(ChatMessage::System(
289            "⚠ abort context from previous session will be injected into next message"
290                .to_string(),
291        ));
292    }
293    app.last_turn_context_window = snapshot.view.context_window;
294    app
295}
296
297/// Terminal + render thread + channels — shared by the in-process boot and
298/// the socket attach (`run_attached`).
299#[allow(clippy::too_many_arguments)]
300pub(crate) async fn finish_setup(
301    mut app: App,
302    transport: Box<dyn ClientTransport>,
303    http: LazyHttp,
304    mode: TransportMode,
305    config: synaps_cli::SynapsConfig,
306    registry: std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
307    keybind_registry: std::sync::Arc<
308        std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>,
309    >,
310    system_prompt_path: std::path::PathBuf,
311    ext_mgr_shared: Option<
312        std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
313    >,
314) -> Result<RunContext> {
315    let link = SessionLink::new(transport);
316    app.keybinds = Some(keybind_registry.clone());
317    // ── Terminal setup + render thread ──
318    //
319    // The Terminal is moved into the render thread immediately after creation.
320    // The main task never touches it again.  All terminal I/O (draw, clear,
321    // teardown) goes through `render_handle`.
322    //
323    // Terminal size for build_render_model: we call crossterm::terminal::size()
324    // directly — it reads the TTY fd without needing the Terminal object.
325    // See render_thread.rs module comment for the design rationale.
326    // P16.3: `setup_terminal` runs BEFORE the DA1 burst below (raw mode must be
327    // enabled first), so no negotiated facts exist at the kitty-push site yet.
328    // Pass `None` ⇒ blind best-effort push, byte-identical with today. See the
329    // `setup_terminal` doc for why the push can't be fact-gated at this site.
330    let terminal = setup_terminal(None)?;
331    ladder_stage("terminal", &{
332        let (c, r) = crossterm::terminal::size().unwrap_or((0, 0));
333        format!("cols={c} rows={r}")
334    });
335
336    // ── P16.2: DA1-fenced terminal capability query burst ──
337    //
338    // SINGLE-CONSUMER ORDERING — LOAD-BEARING (crossterm #963/#993, see
339    // termcaps.rs module docs + the substrate memo). This emits the batched
340    // query burst and reads the replies DIRECTLY from fd 0 under a hard
341    // deadline. That is safe if and only if, by construction:
342    //   1. raw mode is already enabled — `setup_terminal()` ABOVE — so
343    //      replies arrive unechoed and unbuffered by the line discipline;
344    //   2. NO other stdin consumer exists yet — `EventStream::new()` is
345    //      created BELOW, and nothing in this crate touches
346    //      crossterm::event::{poll,read} before it, so crossterm's internal
347    //      reader has not been spawned. The bounded read completes (DA1
348    //      fence or timeout) and releases fd 0 before the EventStream is
349    //      constructed. It even runs before `spawn_render_thread` so no
350    //      other thread writes to the terminal during the reply window.
351    // Timeout / partial replies ⇒ env-detected caps unchanged (= today's
352    // behavior) ⇒ boot proceeds normally. NEVER move this below
353    // `EventStream::new()`; NEVER add a second stdin reader for it.
354    let t_caps = Instant::now();
355    let term_caps =
356        termcaps::negotiate(termcaps::TermCaps::detect(), termcaps::BURST_TIMEOUT).await;
357    ladder_stage("termcaps", &format_args!("burst_ms={}", t_caps.elapsed().as_millis()));
358
359    // P16.3: hand the negotiated caps to the render thread so `render_frame`
360    // can gate edge-scrub (tmux provenance) and synchronized-output (mode 2026)
361    // on facts. Cloned because `term_caps` is also returned in `RunContext`.
362    let (render_handle, boot_done, exit_done) = spawn_render_thread(terminal, term_caps.clone());
363    ladder_stage("render_thread", &"");
364    // Boot effect is sent via the command channel so the render thread owns it.
365    // SYNAPS_NO_BOOT_FX=1 skips it (slow/high-latency links, screen readers).
366    if std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1") {
367        render_handle.send_boot_fx(boot_effect());
368    }
369
370    let event_reader = EventStream::new();
371    let (shutdown_signal_tx, shutdown_signal_rx) = tokio::sync::mpsc::unbounded_channel();
372    let shutdown_signal_task = signals::spawn_shutdown_signal_task_with(
373        shutdown_signal_tx,
374        signals::SignalBackend::for_socket(matches!(mode, TransportMode::Socket)),
375    );
376    ladder_stage("event_stream", &"");
377    let (secret_prompt_tx, secret_prompt_rx) = tokio::sync::mpsc::unbounded_channel();
378    let prompt_bridge = PromptBridge::new(secret_prompt_tx);
379    let secret_prompt_rx = std::sync::Arc::new(std::sync::Mutex::new(secret_prompt_rx));
380    // P7.8: the secret-prompt queue now lives on `app.secret_prompts` (§5).
381    // The mpsc channel wiring above is unchanged; only the queue moved onto
382    // App so the pane handler / harness share production state.
383    // ── Event loop ──
384    // Track whether the render thread currently has an active boot or exit
385    // effect.  The render thread owns the actual Effect values; we track
386    // "has been sent and not yet done" on the main side for the tick throttle.
387    let boot_fx_sent = std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1");
388    let exit_fx_sent = false;
389    let last_draw = Instant::now() - std::time::Duration::from_secs(1);
390
391    Ok(RunContext {
392        app,
393        link,
394        http,
395        prompt_bridge,
396        mode,
397        config,
398        registry,
399        keybind_registry,
400        system_prompt_path,
401        render_handle,
402        boot_done,
403        exit_done,
404        term_caps,
405        event_reader,
406        shutdown_signal_rx,
407        shutdown_signal_task,
408        secret_prompt_rx,
409        ext_mgr_shared,
410        boot_fx_sent,
411        exit_fx_sent,
412        last_draw,
413    })
414}
415
416/// The session envelope stream ended. In-process: the actor is gone — leave.
417/// Socket (A4): reconnect with backoff and re-mirror; `false` = give up.
418///
419/// F9: when the daemon died (not a reload), show "daemon connection lost"
420/// immediately, clear streaming state, and update the line on each retry.
421/// On success show the reconnect notice. On failure set `app.daemon_lost`
422/// for the caller to exit non-zero.
423pub(crate) async fn try_reconnect(
424    app: &mut App,
425    link: &mut SessionLink,
426    mode: &TransportMode,
427    render_handle: &super::render_thread::RenderHandle,
428    registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
429) -> bool {
430    match mode {
431        TransportMode::Local { .. } => false,
432        TransportMode::Socket => {
433            use agent_engine::session::socket_transport::SocketTransport;
434            use std::time::Duration;
435
436            let attach_mode = link.mode();
437            // Snapshot transport state before entering the loop (avoids
438            // holding a `&mut` on link across force_publish / refresh_view).
439            let is_reload = link.transport_mut().is_reload_pending();
440            let daemon_pid = link.transport_mut().daemon_pid();
441            let session_id = link.transport_mut().session_id().clone();
442            let budget = SocketTransport::reconnect_budget();
443            let budget_secs = budget.as_secs();
444
445            // F9: clear streaming state — a turn cannot be streaming without a daemon.
446            if app.streaming {
447                app.streaming = false;
448            }
449            app.compacting = false;
450
451            // Show the initial message (reload = existing text, crash = F9 notice).
452            if !is_reload {
453                app.push_msg(ChatMessage::System(format!(
454                    "daemon connection lost — reconnecting ({budget_secs}s left)…"
455                )));
456                app.request_redraw();
457                force_publish(app, link, registry, render_handle);
458            }
459
460            let deadline = tokio::time::Instant::now() + budget;
461            let mut backoff = Duration::from_millis(100);
462            let mut attempts = 0u32;
463
464            loop {
465                match link.transport_mut().reconnect_once(attach_mode).await {
466                    Ok(snapshot) => {
467                        link.refresh_view();
468                        let new_pid = link.transport_mut().daemon_pid();
469                        let lifecycle = &snapshot.meta.lifecycle;
470                        let status = format!("{lifecycle:?}");
471                        remirror(app, &snapshot);
472                        app.push_msg(ChatMessage::System(format!(
473                            "reconnected to daemon (pid {new_pid}) — session {} {status}",
474                            session_id
475                        )));
476                        app.toasts.upsert(
477                            toast::Toast::new("reload", "reconnected").titled("Daemon"),
478                        );
479                        app.request_redraw();
480                        return true;
481                    }
482                    Err(agent_engine::session::TransportError::Refused(_))
483                    | Err(agent_engine::session::TransportError::Version { .. }) => {
484                        // Terminal errors — stop retrying.
485                        break;
486                    }
487                    Err(_e) => {
488                        attempts += 1;
489                    }
490                }
491
492                if tokio::time::Instant::now() + backoff > deadline {
493                    break;
494                }
495
496                // Update the countdown message (at least on each retry).
497                if !is_reload {
498                    let left = deadline.duration_since(tokio::time::Instant::now());
499                    let secs_left = left.as_secs();
500                    app.status_text = Some(format!(
501                        "reconnecting… ({secs_left}s left, attempt {attempts})"
502                    ));
503                    app.request_redraw();
504                    force_publish(app, link, registry, render_handle);
505                }
506
507                tokio::time::sleep(backoff).await;
508                backoff = (backoff * 2).min(Duration::from_secs(5));
509            }
510
511            // Reconnect failed.
512            app.status_text = None;
513            if !is_reload {
514                app.daemon_lost = Some(super::app::DaemonLostInfo {
515                    pid: daemon_pid,
516                    session_id: session_id.to_string(),
517                    budget_secs,
518                });
519            }
520            app.push_msg(ChatMessage::Error(format!(
521                "lost the daemon (pid {daemon_pid}) — could not reconnect within {budget_secs}s"
522            )));
523            app.request_redraw();
524            false
525        }
526    }
527}
528
529/// Push one render frame from inside `try_reconnect` (the main event loop is
530/// blocked, so we replicate the build+publish path directly).
531fn force_publish(
532    app: &mut App,
533    link: &mut SessionLink,
534    registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
535    render_handle: &super::render_thread::RenderHandle,
536) {
537    if let Ok((w, h)) = crossterm::terminal::size() {
538        if w > 0 && h > 0 {
539            let size = ratatui::layout::Size { width: w, height: h };
540            let built = super::draw::build_render_model(
541                &mut super::view_model::ViewInputs::from_app(app),
542                &**link.view(),
543                registry,
544                size,
545            );
546            if let Some((model, patch)) = built {
547                patch.apply(app);
548                render_handle.publish(model);
549            }
550        }
551    }
552}
553
554/// Digest attaches carry a daemon-projected `display_tail`; Full attaches
555/// carry the history and project it locally — same filter either way.
556fn rebuild_display_from_snapshot(app: &mut App, snapshot: &AttachSnapshot) {
557    match &snapshot.display_tail {
558        Some(tail) => apply_display_tail(tail, app),
559        None => rebuild_display_messages(&snapshot.conversation.api_messages, app),
560    }
561}
562
563/// Rebuild the transcript from an `AttachSnapshot` (reconnect / re-attach):
564/// the cancelled turn's partial text is gone by design (§2.8 step 7).
565pub(crate) fn remirror(app: &mut App, snapshot: &AttachSnapshot) {
566    app.transcript.clear();
567    app.invalidate();
568    app.apply_conversation(&snapshot.conversation);
569    rebuild_display_from_snapshot(app, snapshot);
570    app.streaming = snapshot.streaming;
571    app.last_turn_context_window = snapshot.view.context_window;
572}