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