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        mcp_discovery: agent_engine::McpDiscovery::Inline,
146    })
147    .await?;
148    let handle = host
149        .create_session(SessionConfig {
150            continue_session,
151            system,
152            prompt_manifest,
153            cwd: None,
154            env: None,
155            env_stripped: Vec::new(),
156            auto_approve_confirms: false,
157            model_override: None,
158            persist: true,
159            auto_compact: false,
160            compaction_policy: CompactionPolicyWire::LinkedSuccessor,
161            await_extensions: false,
162            keep_warm: false,
163            name: None,
164            max_session_cost: None,
165        })
166        .await?;
167    let (transport, snapshot) =
168        LocalTransport::attach_with(handle, ClientMeta::new(ClientKind::Tui), AttachMode::Mirror)
169            .await
170            .map_err(|e| synaps_cli::RuntimeError::Config(format!("attach: {e}")))?;
171
172    let config: synaps_cli::SynapsConfig = (**host.config()).clone();
173    let registry = Arc::clone(host.command_registry());
174    let keybind_registry = Arc::clone(host.keybind_registry());
175    let mcp_server_count = host.mcp_server_count();
176    let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
177    let http = LazyHttp::from(host.parts().client.clone());
178    let ext_mgr_shared = Arc::clone(host.ext_manager());
179
180    let mut app = app_from_snapshot(&snapshot);
181    app.keybinds = Some(keybind_registry.clone());
182
183    // Notices queued before boot (daemon auto-spawn fallback) go first.
184    for n in take_boot_notices() {
185        app.push_msg(ChatMessage::System(n));
186    }
187    // Surface config parse warnings once at startup (unknown keys, bad values).
188    for w in &config.warnings {
189        app.push_msg(ChatMessage::System(format!("⚠ config: {}", w)));
190    }
191
192    // First-run guidance: no Anthropic credentials and no provider keys means
193    // the first message will fail — tell the user up front instead.
194    {
195        // Capability queries via the broker boundary — no credential file or
196        // credential env reads here, and answers are booleans only.
197        let has_anthropic = synaps_cli::auth::broker::anthropic_credential_available();
198        let has_provider_key =
199            !synaps_cli::auth::broker::configured_static_provider_keys().is_empty();
200        if !has_anthropic && !has_provider_key {
201            app.push_msg(ChatMessage::System(
202                "👋 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(),
203            ));
204        }
205    }
206
207    if mcp_server_count > 0 {
208        tracing::info!(
209            "{} MCP tools available from configured servers",
210            mcp_server_count
211        );
212    }
213
214    let mut ctx = finish_setup(
215        app,
216        Box::new(transport),
217        http,
218        TransportMode::Local { host },
219        config,
220        registry,
221        keybind_registry,
222        system_prompt_path,
223        Some(ext_mgr_shared),
224    )
225    .await?;
226
227    // Legacy sidecar key migration
228    loop_arms::migrate_sidecar_toggle_key_to_claimed_plugins(&ctx.registry.lifecycle_claims());
229
230    if !no_extensions {
231        let session_id_for_hook = ctx.app.session.id.clone();
232        ctx.app.extension_loader_running = true;
233        ctx.app.toasts.upsert(
234            toast::Toast::new("extension-loader", "Discovering extensions…")
235                .titled("Extensions")
236                .at(toast::ToastPosition::TOP_CENTER)
237                .ttl(None),
238        );
239        // on_session_start is emitted by the extension loader once
240        // subscribers exist — same moment, same bus as before the port.
241        // (When the actor's `await_extensions=false` waiter lands (B1) this
242        // becomes `None` — the actor emits it.)
243        synaps_cli::extensions::loader::spawn_discover_and_load(
244            std::sync::Arc::clone(ctx.ext_mgr_shared.as_ref().expect("local mode")),
245            ctx.app.extension_loader_tx.clone(),
246            Some(session_id_for_hook),
247        );
248    }
249    Ok(ctx)
250}
251
252/// Build `App` from the attach snapshot — the same lines and texts the
253/// `EngineBoot`-based construction produced (`continued` branch included).
254pub(crate) fn app_from_snapshot(snapshot: &AttachSnapshot) -> App {
255    let conv = &snapshot.conversation;
256    let session = app::session_from_header(&conv.header);
257    // F2: apply conversation + display unconditionally when there is history.
258    // `continued` only gates the "resumed session …" notice — a fresh session
259    // re-attached after parking has history but `continued == false`.
260    let has_history = conv.messages_len > 0
261        || !conv.api_messages.is_empty()
262        || snapshot
263            .display_tail
264            .as_ref()
265            .is_some_and(|t| !t.items.is_empty());
266    let mut app = App::new_with_clock(session, clock::TuiClock::real());
267    if has_history {
268        app.apply_conversation(conv);
269        rebuild_display_from_snapshot(&mut app, snapshot);
270    }
271    if snapshot.meta.continued {
272        app.push_msg(ChatMessage::System(format!(
273            "resumed session {}",
274            conv.header.id
275        )));
276        if let Some(ref info) = snapshot.meta.continue_info {
277            if let Some(ref via) = info.resolved_via {
278                app.push_msg(ChatMessage::System(format!(
279                    "  ↳ resolved via {} '{}'",
280                    via, info.query
281                )));
282            }
283            if let Some(ref notice) = info.compaction_notice {
284                app.push_msg(ChatMessage::System(format!("  ↳ {}", notice)));
285            }
286        }
287    }
288    app.last_turn_context_window = snapshot.view.context_window;
289    app
290}
291
292/// Terminal + render thread + channels — shared by the in-process boot and
293/// the socket attach (`run_attached`).
294#[allow(clippy::too_many_arguments)]
295pub(crate) async fn finish_setup(
296    mut app: App,
297    transport: Box<dyn ClientTransport>,
298    http: LazyHttp,
299    mode: TransportMode,
300    config: synaps_cli::SynapsConfig,
301    registry: std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
302    keybind_registry: std::sync::Arc<
303        std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>,
304    >,
305    system_prompt_path: std::path::PathBuf,
306    ext_mgr_shared: Option<
307        std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
308    >,
309) -> Result<RunContext> {
310    let link = SessionLink::new(transport);
311    app.keybinds = Some(keybind_registry.clone());
312    // ── Terminal setup + render thread ──
313    //
314    // The Terminal is moved into the render thread immediately after creation.
315    // The main task never touches it again.  All terminal I/O (draw, clear,
316    // teardown) goes through `render_handle`.
317    //
318    // Terminal size for build_render_model: we call crossterm::terminal::size()
319    // directly — it reads the TTY fd without needing the Terminal object.
320    // See render_thread.rs module comment for the design rationale.
321    // P16.3: `setup_terminal` runs BEFORE the DA1 burst below (raw mode must be
322    // enabled first), so no negotiated facts exist at the kitty-push site yet.
323    // Pass `None` ⇒ blind best-effort push, byte-identical with today. See the
324    // `setup_terminal` doc for why the push can't be fact-gated at this site.
325    let terminal = setup_terminal(None)?;
326    ladder_stage("terminal", &{
327        let (c, r) = crossterm::terminal::size().unwrap_or((0, 0));
328        format!("cols={c} rows={r}")
329    });
330
331    // ── P16.2: DA1-fenced terminal capability query burst ──
332    //
333    // SINGLE-CONSUMER ORDERING — LOAD-BEARING (crossterm #963/#993, see
334    // termcaps.rs module docs + the substrate memo). This emits the batched
335    // query burst and reads the replies DIRECTLY from fd 0 under a hard
336    // deadline. That is safe if and only if, by construction:
337    //   1. raw mode is already enabled — `setup_terminal()` ABOVE — so
338    //      replies arrive unechoed and unbuffered by the line discipline;
339    //   2. NO other stdin consumer exists yet — `EventStream::new()` is
340    //      created BELOW, and nothing in this crate touches
341    //      crossterm::event::{poll,read} before it, so crossterm's internal
342    //      reader has not been spawned. The bounded read completes (DA1
343    //      fence or timeout) and releases fd 0 before the EventStream is
344    //      constructed. It even runs before `spawn_render_thread` so no
345    //      other thread writes to the terminal during the reply window.
346    // Timeout / partial replies ⇒ env-detected caps unchanged (= today's
347    // behavior) ⇒ boot proceeds normally. NEVER move this below
348    // `EventStream::new()`; NEVER add a second stdin reader for it.
349    let t_caps = Instant::now();
350    let term_caps =
351        termcaps::negotiate(termcaps::TermCaps::detect(), termcaps::BURST_TIMEOUT).await;
352    ladder_stage("termcaps", &format_args!("burst_ms={}", t_caps.elapsed().as_millis()));
353
354    // P16.3: hand the negotiated caps to the render thread so `render_frame`
355    // can gate edge-scrub (tmux provenance) and synchronized-output (mode 2026)
356    // on facts. Cloned because `term_caps` is also returned in `RunContext`.
357    let (render_handle, boot_done, exit_done) = spawn_render_thread(terminal, term_caps.clone());
358    ladder_stage("render_thread", &"");
359    // Boot effect is sent via the command channel so the render thread owns it.
360    // SYNAPS_NO_BOOT_FX=1 skips it (slow/high-latency links, screen readers).
361    if std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1") {
362        render_handle.send_boot_fx(boot_effect());
363    }
364
365    let event_reader = EventStream::new();
366    let (shutdown_signal_tx, shutdown_signal_rx) = tokio::sync::mpsc::unbounded_channel();
367    let shutdown_signal_task = signals::spawn_shutdown_signal_task_with(
368        shutdown_signal_tx,
369        signals::SignalBackend::for_socket(matches!(mode, TransportMode::Socket)),
370    );
371    ladder_stage("event_stream", &"");
372    let (secret_prompt_tx, secret_prompt_rx) = tokio::sync::mpsc::unbounded_channel();
373    let prompt_bridge = PromptBridge::new(secret_prompt_tx);
374    let secret_prompt_rx = std::sync::Arc::new(std::sync::Mutex::new(secret_prompt_rx));
375    // P7.8: the secret-prompt queue now lives on `app.secret_prompts` (§5).
376    // The mpsc channel wiring above is unchanged; only the queue moved onto
377    // App so the pane handler / harness share production state.
378    // ── Event loop ──
379    // Track whether the render thread currently has an active boot or exit
380    // effect.  The render thread owns the actual Effect values; we track
381    // "has been sent and not yet done" on the main side for the tick throttle.
382    let boot_fx_sent = std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1");
383    let exit_fx_sent = false;
384    let last_draw = Instant::now() - std::time::Duration::from_secs(1);
385
386    Ok(RunContext {
387        app,
388        link,
389        http,
390        prompt_bridge,
391        mode,
392        config,
393        registry,
394        keybind_registry,
395        system_prompt_path,
396        render_handle,
397        boot_done,
398        exit_done,
399        term_caps,
400        event_reader,
401        shutdown_signal_rx,
402        shutdown_signal_task,
403        secret_prompt_rx,
404        ext_mgr_shared,
405        boot_fx_sent,
406        exit_fx_sent,
407        last_draw,
408    })
409}
410
411/// The session envelope stream ended. In-process: the actor is gone — leave.
412/// Socket (A4): reconnect with backoff and re-mirror; `false` = give up.
413///
414/// F9: when the daemon died (not a reload), show "daemon connection lost"
415/// immediately, clear streaming state, and update the line on each retry.
416/// On success show the reconnect notice. On failure set `app.daemon_lost`
417/// for the caller to exit non-zero.
418pub(crate) async fn try_reconnect(
419    app: &mut App,
420    link: &mut SessionLink,
421    mode: &TransportMode,
422    render_handle: &super::render_thread::RenderHandle,
423    registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
424) -> bool {
425    match mode {
426        TransportMode::Local { .. } => false,
427        TransportMode::Socket => {
428            use agent_engine::session::socket_transport::SocketTransport;
429            use std::time::Duration;
430
431            let attach_mode = link.mode();
432            // Snapshot transport state before entering the loop (avoids
433            // holding a `&mut` on link across force_publish / refresh_view).
434            let is_reload = link.transport_mut().is_reload_pending();
435            let daemon_pid = link.transport_mut().daemon_pid();
436            let session_id = link.transport_mut().session_id().clone();
437            let budget = SocketTransport::reconnect_budget();
438            let budget_secs = budget.as_secs();
439
440            // F9: clear streaming state — a turn cannot be streaming without a daemon.
441            if app.streaming {
442                app.streaming = false;
443            }
444            app.compacting = false;
445
446            // Show the initial message (reload = existing text, crash = F9 notice).
447            if !is_reload {
448                app.push_msg(ChatMessage::System(format!(
449                    "daemon connection lost — reconnecting ({budget_secs}s left)…"
450                )));
451                app.request_redraw();
452                force_publish(app, link, registry, render_handle);
453            }
454
455            let deadline = tokio::time::Instant::now() + budget;
456            let mut backoff = Duration::from_millis(100);
457            let mut attempts = 0u32;
458
459            loop {
460                match link.transport_mut().reconnect_once(attach_mode).await {
461                    Ok(snapshot) => {
462                        link.refresh_view();
463                        let new_pid = link.transport_mut().daemon_pid();
464                        let lifecycle = &snapshot.meta.lifecycle;
465                        let status = format!("{lifecycle:?}");
466                        remirror(app, &snapshot);
467                        app.push_msg(ChatMessage::System(format!(
468                            "reconnected to daemon (pid {new_pid}) — session {} {status}",
469                            session_id
470                        )));
471                        app.toasts.upsert(
472                            toast::Toast::new("reload", "reconnected").titled("Daemon"),
473                        );
474                        app.request_redraw();
475                        return true;
476                    }
477                    Err(agent_engine::session::TransportError::Refused(_))
478                    | Err(agent_engine::session::TransportError::Version { .. }) => {
479                        // Terminal errors — stop retrying.
480                        break;
481                    }
482                    Err(_e) => {
483                        attempts += 1;
484                    }
485                }
486
487                if tokio::time::Instant::now() + backoff > deadline {
488                    break;
489                }
490
491                // Update the countdown message (at least on each retry).
492                if !is_reload {
493                    let left = deadline.duration_since(tokio::time::Instant::now());
494                    let secs_left = left.as_secs();
495                    app.status_text = Some(format!(
496                        "reconnecting… ({secs_left}s left, attempt {attempts})"
497                    ));
498                    app.request_redraw();
499                    force_publish(app, link, registry, render_handle);
500                }
501
502                tokio::time::sleep(backoff).await;
503                backoff = (backoff * 2).min(Duration::from_secs(5));
504            }
505
506            // Reconnect failed.
507            app.status_text = None;
508            if !is_reload {
509                app.daemon_lost = Some(super::app::DaemonLostInfo {
510                    pid: daemon_pid,
511                    session_id: session_id.to_string(),
512                    budget_secs,
513                });
514            }
515            app.push_msg(ChatMessage::Error(format!(
516                "lost the daemon (pid {daemon_pid}) — could not reconnect within {budget_secs}s"
517            )));
518            app.request_redraw();
519            false
520        }
521    }
522}
523
524/// Push one render frame from inside `try_reconnect` (the main event loop is
525/// blocked, so we replicate the build+publish path directly).
526fn force_publish(
527    app: &mut App,
528    link: &mut SessionLink,
529    registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
530    render_handle: &super::render_thread::RenderHandle,
531) {
532    if let Ok((w, h)) = crossterm::terminal::size() {
533        if w > 0 && h > 0 {
534            let size = ratatui::layout::Size { width: w, height: h };
535            let built = super::draw::build_render_model(
536                &mut super::view_model::ViewInputs::from_app(app),
537                &**link.view(),
538                registry,
539                size,
540            );
541            if let Some((model, patch)) = built {
542                patch.apply(app);
543                render_handle.publish(model);
544            }
545        }
546    }
547}
548
549/// Digest attaches carry a daemon-projected `display_tail`; Full attaches
550/// carry the history and project it locally — same filter either way.
551fn rebuild_display_from_snapshot(app: &mut App, snapshot: &AttachSnapshot) {
552    match &snapshot.display_tail {
553        Some(tail) => apply_display_tail(tail, app),
554        None => rebuild_display_messages(&snapshot.conversation.api_messages, app),
555    }
556}
557
558/// Rebuild the transcript from an `AttachSnapshot` (reconnect / re-attach):
559/// the cancelled turn's partial text is gone by design (§2.8 step 7).
560pub(crate) fn remirror(app: &mut App, snapshot: &AttachSnapshot) {
561    app.transcript.clear();
562    app.invalidate();
563    app.apply_conversation(&snapshot.conversation);
564    rebuild_display_from_snapshot(app, snapshot);
565    app.streaming = snapshot.streaming;
566    app.last_turn_context_window = snapshot.view.context_window;
567}