Skip to main content

mermaid_cli/app/
run.rs

1//! The ~30-line main loop.
2//!
3//! Single entry point that composes crossterm events with the engine:
4//!
5//! ```text
6//!   crossterm events ──┐
7//!                      ├── tokio::select! ── Msg ── Engine::step ──┐
8//!   effect results   ──┤                                            │
9//!                      │                            ▲               │
10//!   tick             ──┘                            └─ Msg back ◄───┘
11//! ```
12//!
13//! No parallel event loops, no observer callbacks, no polling. One select!,
14//! one `step` per message, effects dispatched into structured concurrency per
15//! turn.
16//!
17//! The reduce-and-route half lives in [`crate::engine`], shared with the
18//! headless drivers. What stays here is what is genuinely run-loop-owned: the
19//! terminal event stream (the `$EDITOR` round-trip has to drop and rebuild it
20//! around a suspend), the 16ms render tick, and `Cmd::ComposeInEditor`, which
21//! [`RunLoopSink`] peels out of the command flow for exactly that reason.
22
23use std::collections::VecDeque;
24use std::path::PathBuf;
25
26use anyhow::Result;
27use crossterm::event::EventStream;
28use futures::{FutureExt, StreamExt};
29use ratatui::layout::Rect;
30use tokio::time::{Duration, interval};
31
32use crate::app::event_source::{bridge_paste_chunks, coalesce_key_burst_seamed};
33use crate::app::lifecycle::RuntimeLifecycle;
34use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder, SessionHeader};
35use crate::app::terminal::TerminalGuard;
36use crate::effect::EffectRunner;
37use crate::engine::{EffectSink, Engine, Observation, StepObserver};
38use crate::providers::ToolRegistry;
39use crate::render::{RenderCache, render};
40use mermaid_domain::Config;
41use mermaid_domain::ConversationHistory;
42use mermaid_domain::{Cmd, Msg, Paste, RuntimeSignal, State};
43
44/// Options for `run_interactive_with`. Added so new flags land without
45/// reshuffling positional args.
46///
47/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
48/// Debug. The bigger picture is that nothing prints these — they're an
49/// argument bundle, not telemetry.
50#[derive(Default)]
51pub struct InteractiveOptions {
52    /// Optional recorder for `--record <file>` JSONL capture.
53    pub recorder: Option<Recorder>,
54    /// Optional conversation to seed the session with (e.g. from
55    /// `--continue` or `--sessions`). When `Some`, the seeded history
56    /// replaces `State::session.conversation` before the first frame.
57    pub seed_conversation: Option<ConversationHistory>,
58}
59
60/// The interactive loop's effect sink: everything reaches the runner except
61/// `Cmd::ComposeInEditor`, which is run-loop-owned — it suspends the terminal
62/// and drops the crossterm event stream, and only this loop holds either. At
63/// most one compose lands per reducer step by construction (a single Ctrl+O /
64/// `/editor` arm).
65struct RunLoopSink {
66    runner: EffectRunner,
67    compose: Option<String>,
68}
69
70impl EffectSink for RunLoopSink {
71    fn dispatch(&mut self, cmd: Cmd) {
72        if let Cmd::ComposeInEditor { text } = cmd {
73            self.compose = Some(text);
74        } else {
75            self.runner.dispatch(cmd);
76        }
77    }
78}
79
80/// `--record <file>`: one JSONL line per reducer input.
81///
82/// As an observer it writes *before* the reducer runs, so the log captures even
83/// no-op inputs, and it carries the exact `now` the message was reduced under —
84/// which is what lets `--replay` stamp each entry's `ts` back into `state.now`
85/// and recompute the identical states.
86struct RecorderTap(Option<Recorder>);
87
88impl StepObserver for RecorderTap {
89    fn observe(&mut self, obs: Observation<'_>) {
90        if let Some(r) = self.0.as_mut()
91            && let Err(err) = r.record_msg(obs.now, obs.msg)
92        {
93            tracing::warn!(error = %err, "recorder: failed to record message; --replay may be non-deterministic");
94        }
95    }
96}
97
98/// Interactive TUI main loop with explicit options. `recorder` (if
99/// provided) appends one JSONL line per reducer input to the file for
100/// debugging / replay.
101///
102/// # Errors
103///
104/// Setting up the terminal, opening the recorder when one is requested, and a
105/// failure in the main loop or in the shutdown that follows it. A model or
106/// tool that fails mid-session is not among them: those surface in the
107/// transcript and the loop continues, which is what makes a session survivable.
108#[expect(
109    clippy::too_many_lines,
110    reason = "the interactive main loop: boot wiring, the select! over terminal events, effect \
111     results, signals and ticks, and the ordered shutdown all share one scope because the \
112     terminal guard, event stream and engine must be created, suspended for $EDITOR, and dropped \
113     in a fixed order; moving any stage out would hand that order to call sites"
114)]
115pub async fn run_interactive_with(
116    mut config: Config,
117    cwd: PathBuf,
118    model_id: String,
119    mut opts: InteractiveOptions,
120) -> Result<()> {
121    // One startup clock read, shared by `State::new` and the recording
122    // header: replay seeds `State::new` with the recorded value and gets the
123    // same initial conversation id/title.
124    let startup_now = chrono::Local::now();
125    // Fold enabled plugins' MCP servers + agent types into the merged config
126    // BEFORE anything consumes it (State::new seeds server rows, the
127    // recording header captures the merged config — replay-faithful, and the
128    // provider factory + tool registry see the same view).
129    let plugin_assets = crate::app::plugin_assets::load();
130    let plugin_warnings = crate::app::plugin_assets::apply(&mut config, &plugin_assets);
131    let mut state = State::new(
132        config.clone(),
133        cwd.clone(),
134        model_id.clone(),
135        startup_now,
136        std::env::temp_dir(),
137    );
138    let seed = opts.seed_conversation.take();
139    if let Some(r) = opts.recorder.as_mut() {
140        // The header makes a recording self-contained: `--replay` rebuilds
141        // the initial State from it (config, model, cwd, seed) without
142        // reading this machine's live config. Written before the first Msg
143        // so even a crashed session leaves a parseable log.
144        r.record_header(&SessionHeader {
145            format: RECORDING_FORMAT_VERSION,
146            ts: startup_now,
147            model_id: model_id.clone(),
148            cwd: cwd.clone(),
149            config: config.clone(),
150            seed_conversation: seed.clone(),
151        })?;
152    }
153    if let Some(history) = seed {
154        // `--continue` / `--resume` seed — shared with `--replay` via
155        // `State::seed_conversation` so both build the same starting state.
156        state.seed_conversation(history);
157    }
158    state
159        .ui
160        .pending_msgs
161        .push_back(Msg::SessionProvenanceResolved(
162            crate::session::probe_session_provenance(&cwd),
163        ));
164    // NO_COLOR (https://no-color.org): present and non-empty disables all
165    // color. Read once here — the reducer never touches the environment; the
166    // render layer resolves `Theme::plain()` off this flag.
167    state.ui.no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
168    // Skills load once at startup (authored artifacts, no watcher); the config
169    // watcher below keeps only instructions/memory fresh.
170    state.skills = crate::app::skills::load(&cwd);
171    // Plugin prompt commands: same restart-to-refresh policy as skills.
172    state.plugin_commands = plugin_assets.commands;
173    for warning in plugin_warnings {
174        state
175            .ui
176            .pending_msgs
177            .push_back(Msg::TransientStatus { text: warning });
178    }
179    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
180    let tools = ToolRegistry::build(&config, providers.clone());
181    if let Some(capabilities) = tools.web_capabilities()
182        && let Some(text) = web_capabilities_notice(&config, capabilities)
183    {
184        state
185            .ui
186            .pending_msgs
187            .push_back(Msg::TransientStatus { text });
188    }
189    if let Some(text) = crate::app::output_styles::output_style_notice(&config) {
190        state
191            .ui
192            .pending_msgs
193            .push_back(Msg::TransientStatus { text });
194    }
195    let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
196    // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
197    // escalations) pause and prompt instead of erroring out, and inline
198    // `ask_user_question` prompts so the model can ask the user structured
199    // questions mid-run instead of proceeding without them.
200    let mut runner = runner
201        .with_interactive_approvals()
202        .with_interactive_questions();
203    // Keep instructions/memory fresh via the background config watcher (#45):
204    // it emits Msg::InstructionsChanged/MemoryChanged on change, so the reducer
205    // reads them as injected data and never does the refresh I/O inline.
206    runner.spawn_config_watcher(cwd.clone(), config.memory.clone());
207    let mut terminal = Some(TerminalGuard::setup()?);
208    let home_dir = directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
209    let mut rstate = RenderCache::new(home_dir);
210    // `Option` because the $EDITOR compose round-trip must DROP the stream
211    // (its reader thread holds crossterm's internal reader mutex) before
212    // suspending, and build a fresh one after — same lifecycle dance as
213    // `terminal` above.
214    let mut events = Some(EventStream::new());
215    let mut lifecycle = RuntimeLifecycle::new();
216    let mut tick = interval(Duration::from_millis(16));
217
218    // Boot effects: MCP server init (if configured). Instructions/memory are
219    // loaded by the config watcher started above (#45), not here.
220    for cmd in bootstrap_cmds(&config, &state.session.conversation.id) {
221        runner.dispatch(cmd);
222    }
223    // A resumed session may carry an in-flight checklist; hand it to the
224    // TaskBroker (tool-side truth) so the first task tool call of the new
225    // process starts from the restored list instead of an empty one.
226    if !state.session.conversation.tasks.tasks.is_empty() {
227        runner.dispatch(mermaid_domain::Cmd::SyncTaskStore(
228            state.session.conversation.tasks.clone(),
229        ));
230    }
231
232    // Everything the reducer needs is in place: hand the state and the runner
233    // to the engine, which owns the reduce-and-route step from here. The
234    // `select!` below stays local — terminal events and the `$EDITOR`
235    // round-trip are genuinely run-loop-owned — but it now feeds one call.
236    let mut engine = Engine::new(
237        state,
238        RunLoopSink {
239            runner,
240            compose: None,
241        },
242    )
243    .with_observer(RecorderTap(opts.recorder));
244
245    // Which `select!` arm fired. Terminal events are handled *after* the
246    // select! returns so the paste-coalescing drain can borrow `events`
247    // again without tripping the borrow checker.
248    //
249    // `Msg` is the large variant, but this enum lives on the stack for one
250    // loop iteration and `Msg` is passed by value everywhere already —
251    // boxing it would add a per-event heap alloc on the hot input path.
252    #[expect(clippy::large_enum_variant)]
253    enum Sel {
254        Msg(Option<Msg>),
255        Term(Option<Result<crossterm::event::Event, std::io::Error>>),
256    }
257
258    // Msgs produced ahead of time — e.g. a non-paste event drained while
259    // coalescing a key burst. Processed before pulling the next event.
260    let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
261
262    // Main loop. A fatal error inside the loop is captured here and returned
263    // AFTER the orderly-shutdown path below, so a draw failure can't skip MCP
264    // child cleanup / pending-save drains (the terminal is still restored by
265    // `TerminalGuard::Drop` regardless).
266    let mut exit_result: Result<()> = Ok(());
267    // Last-seen `full_redraw_seq`. When the reducer bumps it (shell command
268    // finished, Ctrl+L), `Terminal::clear()` resets ratatui's back buffer so
269    // the next draw repaints every cell — the only way to overwrite bytes
270    // some other process wrote directly to the tty (ghost cells).
271    let mut seen_redraw_seq = engine.state().ui.full_redraw_seq;
272    loop {
273        // Render the current state. ratatui's draw closure captures
274        // &State, so we don't thread &mut State through the renderer.
275        {
276            let term = terminal
277                .as_mut()
278                .expect("terminal guard is alive while the render loop runs")
279                .inner_mut();
280            if engine.state().ui.full_redraw_seq != seen_redraw_seq {
281                seen_redraw_seq = engine.state().ui.full_redraw_seq;
282                // NOT `Terminal::clear()`: it snapshots the cursor with an
283                // ESC[6n round-trip, and the reply never arrives — the
284                // `EventStream` reader thread is parked holding crossterm's
285                // internal reader mutex and swallows it — so the query dies
286                // fatally after crossterm's 2s deadline. `resize()` to the
287                // current size performs the same full clear + back-buffer
288                // reset for a Fullscreen viewport without querying the tty.
289                let repaint = term
290                    .size()
291                    .and_then(|size| term.resize(Rect::new(0, 0, size.width, size.height)));
292                if let Err(err) = repaint {
293                    exit_result = Err(err.into());
294                    break;
295                }
296            }
297            if let Err(err) = term.draw(|f| render(engine.state(), &mut rstate, f)) {
298                exit_result = Err(err.into());
299                break;
300            }
301        }
302
303        // Drain any msgs queued by a prior burst-coalesce before blocking
304        // on the next event.
305        let msg = if let Some(queued) = pending_msgs.pop_front() {
306            Some(queued)
307        } else {
308            let selected = tokio::select! {
309                // Fair (unbiased) polling. With `biased;`, the hot `msg_rx`
310                // arm would always win under sustained streaming and starve
311                // terminal input + OS signals (#112). Fair selection still
312                // drains streaming promptly — it's almost always ready — while
313                // guaranteeing the input/signal/tick arms get serviced too.
314                //
315                // Effect results (streaming chunks, tool output, …).
316                m = msg_rx.recv() => Sel::Msg(m),
317                // Crossterm events. Handled below, outside the select!, so
318                // coalescing can re-borrow `events`.
319                e = events.as_mut().expect("event stream is alive while the loop runs").next() => Sel::Term(e),
320                // OS lifecycle signals. A typed Ctrl+C in raw mode is handled
321                // by the crossterm branch above; this covers SIGINT/SIGTERM/
322                // SIGHUP delivered externally.
323                s = lifecycle.next_msg() => Sel::Msg(s),
324                // Tick — drives elapsed-time displays + self-dismissing status
325                // lines without busy-waiting.
326                _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
327            };
328
329            match selected {
330                Sel::Msg(m) => m,
331                Sel::Term(Some(Ok(evt))) => {
332                    if let crossterm::event::Event::Mouse(m) = &evt {
333                        use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
334                        let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
335                        match m.kind {
336                            // F13: Ctrl+Click a chat image tile opens it via
337                            // the system viewer. The screen→image mapping
338                            // lives in ChatState (the render layer).
339                            MEK::Down(MouseButton::Left) if ctrl => rstate
340                                .chat
341                                .find_image_at_screen_pos(m.row)
342                                .map(|target| Msg::OpenImageAt {
343                                    message_index: target.message_index,
344                                    image_index: target.image_index,
345                                    image_number: target.image_number,
346                                }),
347                            // Plain (no-modifier) left drag selects chat text.
348                            // Handled render-side so wheel-scroll + Ctrl+Click
349                            // keep working; on release we copy the selection.
350                            MEK::Down(MouseButton::Left) => {
351                                rstate.chat.begin_selection(m.row, m.column);
352                                None
353                            },
354                            MEK::Drag(MouseButton::Left) => {
355                                rstate.chat.update_selection(m.row, m.column);
356                                None
357                            },
358                            MEK::Up(MouseButton::Left) => {
359                                // A drag only *selects* (the highlight persists);
360                                // copying is an explicit action (Ctrl+Shift+C).
361                                // Auto-copying on release would silently clobber
362                                // the user's clipboard.
363                                None
364                            },
365                            MEK::ScrollUp => Some(Msg::MouseScroll {
366                                delta: mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16,
367                            }),
368                            MEK::ScrollDown => Some(Msg::MouseScroll {
369                                delta: -(mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16),
370                            }),
371                            _ => None,
372                        }
373                    } else {
374                        // Non-mouse event. Ctrl+Shift+C copies the current chat
375                        // selection — the explicit copy step after a drag-select.
376                        // Because the app holds the mouse, the terminal has no
377                        // selection of its own and passes the shortcut through.
378                        // The SHIFT bit only arrives when the kitty keyboard
379                        // protocol was negotiated at setup (TerminalGuard); on
380                        // legacy terminals Ctrl+Shift+C is transmitted as the
381                        // identical byte 0x03 as Ctrl+C — physically
382                        // indistinguishable — so there it falls through to the
383                        // reducer's Ctrl+C handling (press-twice-to-exit keeps
384                        // a stray copy-chord harmless).
385                        if let crossterm::event::Event::Key(k) = &evt
386                            && k.kind == crossterm::event::KeyEventKind::Press
387                            && k.modifiers
388                                .contains(crossterm::event::KeyModifiers::CONTROL)
389                            && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
390                            && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
391                        {
392                            // Route the copy through the reducer (#18): the
393                            // selection lives in the render layer, but emitting a
394                            // Msg keeps the clipboard side effect recorded +
395                            // replayable instead of dispatched out-of-band.
396                            rstate
397                                .chat
398                                .selected_text()
399                                .filter(|t| !t.is_empty())
400                                .map(Msg::CopySelection)
401                        } else {
402                            // Coalesce a paste burst (crossterm 0.29 doesn't
403                            // deliver Event::Paste on the Windows console — a
404                            // paste arrives as a flood of Char/Enter key events).
405                            // The drain pulls every immediately-available event
406                            // so the whole block lands as one atomic Msg::Paste.
407                            let stream = events
408                                .as_mut()
409                                .expect("event stream is alive while the loop runs");
410                            let (mut primary, mut trailing, ends_with_cr) =
411                                coalesce_key_burst_seamed(evt, || {
412                                    stream.next().now_or_never().flatten().and_then(|r| r.ok())
413                                });
414                            // A paste-shaped burst that stopped on plain quiet
415                            // (no deliberate trailing event) may just be the
416                            // first chunk ConPTY delivered — bridge the gap so
417                            // a chunk boundary right after an Enter never
418                            // submits half a paste (#351). The fold state
419                            // crosses the seam with it, so a CRLF pair split
420                            // at the gap stays one newline.
421                            if trailing.is_empty()
422                                && let Some(Msg::Paste(Paste::Text(text))) = &mut primary
423                            {
424                                trailing = bridge_paste_chunks(text, ends_with_cr, stream).await;
425                            }
426                            for queued in trailing {
427                                pending_msgs.push_back(queued);
428                            }
429                            primary
430                        }
431                    }
432                },
433                Sel::Term(Some(Err(error))) => {
434                    tracing::warn!(error = %error, "terminal event stream failed");
435                    None
436                },
437                Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
438            }
439        };
440
441        let Some(msg) = msg else { continue };
442
443        // Inject the wall clock as data (Cause 3): one stamp per tick, shared
444        // by the recorder observer and the reducer. The recorded `ts` IS the
445        // `state.now` this Msg was reduced under, so `--replay` folds the
446        // same log by stamping each entry's `ts` here and recomputes the
447        // exact same states.
448        let outcome = engine.step_at(chrono::Local::now(), msg);
449
450        // The sink peeled `ComposeInEditor` off on its way past; run it here,
451        // where the terminal and event stream it has to suspend actually live.
452        if let Some(draft) = engine.sink_mut().compose.take() {
453            match crate::app::editor::compose_in_editor(&mut terminal, &mut events, draft).await {
454                // Through pending_msgs, so the result flows through the
455                // recorder like any input — --replay never launches an editor.
456                Ok(msg) => pending_msgs.push_back(msg),
457                Err(err) => {
458                    exit_result = Err(err);
459                    break;
460                },
461            }
462        }
463
464        if outcome.should_exit {
465            break;
466        }
467    }
468
469    let (state, sink, RecorderTap(mut recorder)) = engine.into_parts();
470
471    // Seal the recording with a fingerprint of the final session, so a
472    // future `--replay` can verify its fold reproduces what this live
473    // session actually saw — not merely that the fold is self-consistent.
474    // (Wall-clock read is fine here: we're outside the reducer.)
475    if let Some(r) = recorder.as_mut()
476        && let Err(err) = r.record_trailer(chrono::Local::now(), &state.session)
477    {
478        tracing::warn!(error = %err, "recorder: failed to write replay trailer");
479    }
480
481    // Restore the user's terminal before async shutdown. Shutdown can
482    // wait on pending saves / cancelled scopes for a bounded period;
483    // keeping raw mode + mouse capture alive during that wait makes
484    // Ctrl+C feel ignored and can leak mouse escape sequences into
485    // the shell if the user keeps interacting.
486    drop(events);
487    if let Some(mut terminal) = terminal.take() {
488        terminal.restore_now();
489    }
490
491    // Orderly shutdown — wait for any pending saves / scope cleanup. Runs even
492    // when the loop broke on a draw error, so MCP children are reaped cleanly.
493    sink.runner.shutdown().await;
494    exit_result
495}
496
497/// Commands dispatched on startup before the first iteration of the
498/// loop. Fires MCP init (if configured) and materializes the session's
499/// scratch directory. Instructions/memory are loaded by the config
500/// watcher (#45), not here.
501fn bootstrap_cmds(config: &Config, session_id: &str) -> Vec<Cmd> {
502    // Instructions/memory load + stay fresh via the config watcher (#45),
503    // started in `run_interactive_with`.
504    let mut cmds = Vec::new();
505    if !config.mcp_servers.is_empty() {
506        cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
507    }
508    // Every session gets a scratch dir — `session_id` is captured AFTER any
509    // `--continue`/`--resume` seed, so a resumed session adopts the dir
510    // keyed by its restored conversation id.
511    cmds.push(Cmd::EnsureScratchpad {
512        session_id: session_id.to_string(),
513    });
514    cmds
515}
516
517/// One startup-visible summary built from the exact capability resolution used
518/// by the registry and subagents. This makes backend/trust routing explicit in
519/// the TUI without re-reading credentials or probing platform viability.
520///
521/// Returns `None` only for the boring case — every capability resolved AND
522/// every one of them terminates on this machine — so a healthy sovereign
523/// startup stays quiet. Silence therefore means "working and local"; anything
524/// else speaks. Availability alone is deliberately NOT the gate: a working
525/// cloud backend is exactly what a user needs told, so gating on viability
526/// would mute the disclosure precisely when traffic is leaving the machine.
527fn web_capabilities_notice(
528    config: &Config,
529    capabilities: &crate::providers::tool::web::WebCapabilities,
530) -> Option<String> {
531    use crate::providers::tool::web::Egress;
532
533    if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
534        return Some(format!(
535            "Web egress disabled by safety.network = \"deny\" (selected fetch backend: {}; selected search backend: {}).",
536            capabilities.fetch.backend, capabilities.search.backend
537        ));
538    }
539
540    let all = [
541        ("fetch", &capabilities.fetch),
542        ("search", &capabilities.search),
543    ];
544    let degraded = all
545        .into_iter()
546        .filter(|(_, status)| !status.available)
547        .collect::<Vec<_>>();
548    let leaves_machine = all
549        .iter()
550        .any(|(_, status)| status.egress == Egress::OffMachine);
551    if degraded.is_empty() && !leaves_machine {
552        return None;
553    }
554
555    // Headline stays one line per capability: backend + availability, and the
556    // trust destination ONLY where it means something. An unavailable backend
557    // routes nowhere, so naming its destination there is noise that also
558    // strands the remediation text mid-sentence.
559    let headline = |name: &str, status: &crate::providers::tool::web::WebCapabilityStatus| {
560        if status.available {
561            format!(
562                "{name}: {} (available; {})",
563                status.backend, status.trust_destination
564            )
565        } else {
566            format!("{name}: {} (unavailable)", status.backend)
567        }
568    };
569
570    // Remediation prose is a paragraph, not a parenthetical — give each
571    // degraded capability its own line below the headline. The marker is a
572    // `-` bullet, not leading whitespace: the transcript renderer re-wraps
573    // system notices word by word (`wrap_text_with_indent`), so an indent is
574    // dropped and the detail lines would be indistinguishable from the
575    // wrapped headline. A glyph is a word, so it survives.
576    let mut notice = format!(
577        "Web capabilities - {}; {}.",
578        headline("fetch", &capabilities.fetch),
579        headline("search", &capabilities.search)
580    );
581    for (name, status) in degraded {
582        let reason = status
583            .reason
584            .as_deref()
585            .map(mermaid_model::utils::redact_secrets)
586            .unwrap_or_else(|| "backend initialization failed".to_string());
587        let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
588        let reason = mermaid_model::utils::truncate_middle_bytes(&reason, 240)
589            .split_whitespace()
590            .collect::<Vec<_>>()
591            .join(" ");
592        notice.push_str(&format!("\n- {name}: {reason}"));
593    }
594    Some(notice)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn output_style_notice_speaks_only_for_non_default_styles() {
603        use crate::app::output_styles::output_style_notice;
604        assert_eq!(output_style_notice(&Config::default()), None);
605        let mut config = Config::default();
606        config.output.style = "concise".to_string();
607        config.active_style = mermaid_domain::ActiveStyle {
608            body: "x".to_string(),
609            keep_coding_instructions: true,
610            custom: false,
611            source: "user".to_string(),
612        };
613        let notice = output_style_notice(&config).expect("non-default style is announced");
614        assert!(notice.contains("concise"), "{notice}");
615        assert!(notice.contains("built-in"), "{notice}");
616        assert!(notice.contains("user"), "{notice}");
617    }
618
619    #[test]
620    fn bootstrap_always_ensures_the_session_scratchpad() {
621        // Instructions/memory load via the config watcher (#45), not
622        // bootstrap; with no MCP servers configured, only the scratchpad
623        // ensure remains — keyed by the caller's session id.
624        let cmds = bootstrap_cmds(&Config::default(), "sess-1");
625        assert_eq!(cmds.len(), 1);
626        assert!(
627            cmds.iter().any(
628                |c| matches!(c, Cmd::EnsureScratchpad { session_id } if session_id == "sess-1")
629            )
630        );
631    }
632
633    #[test]
634    fn bootstrap_skips_mcp_init_when_no_servers_configured() {
635        let cmds = bootstrap_cmds(&Config::default(), "sess-1");
636        assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
637    }
638
639    #[test]
640    fn bootstrap_includes_mcp_init_when_servers_configured() {
641        let mut cfg = Config::default();
642        cfg.mcp_servers.insert(
643            "example".to_string(),
644            mermaid_domain::McpServerConfig {
645                command: "echo".to_string(),
646                args: vec![],
647                env: std::collections::HashMap::new(),
648                ..Default::default()
649            },
650        );
651        let cmds = bootstrap_cmds(&cfg, "sess-1");
652        assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
653    }
654
655    /// Statuses are built by hand rather than via `WebCapabilities::resolve`
656    /// so the notice's formatting is asserted independently of whichever
657    /// backends happen to be viable on the test host.
658    fn capabilities(
659        fetch: crate::providers::tool::web::WebCapabilityStatus,
660        search: crate::providers::tool::web::WebCapabilityStatus,
661    ) -> crate::providers::tool::web::WebCapabilities {
662        crate::providers::tool::web::WebCapabilities::from_statuses_for_test(fetch, search)
663    }
664
665    fn available(
666        backend: &'static str,
667        trust_destination: &'static str,
668        egress: crate::providers::tool::web::Egress,
669    ) -> crate::providers::tool::web::WebCapabilityStatus {
670        crate::providers::tool::web::WebCapabilityStatus {
671            available: true,
672            backend,
673            trust_destination,
674            egress,
675            reason: None,
676        }
677    }
678
679    fn unavailable(
680        backend: &'static str,
681        trust_destination: &'static str,
682        egress: crate::providers::tool::web::Egress,
683        reason: &str,
684    ) -> crate::providers::tool::web::WebCapabilityStatus {
685        crate::providers::tool::web::WebCapabilityStatus {
686            available: false,
687            backend,
688            trust_destination,
689            egress,
690            reason: Some(reason.to_string()),
691        }
692    }
693
694    /// The two sovereign defaults, spelled once: fetch straight off this
695    /// machine, search via the locally managed SearXNG process.
696    fn local_fetch() -> crate::providers::tool::web::WebCapabilityStatus {
697        available(
698            "native",
699            "direct from this machine",
700            crate::providers::tool::web::Egress::OnMachine,
701        )
702    }
703
704    fn local_search() -> crate::providers::tool::web::WebCapabilityStatus {
705        available(
706            "managed_searxng",
707            "local managed process",
708            crate::providers::tool::web::Egress::OnMachine,
709        )
710    }
711
712    #[test]
713    fn web_capability_notice_stays_silent_when_everything_resolved_and_local() {
714        let config = Config::default();
715        let capabilities = capabilities(local_fetch(), local_search());
716        assert_eq!(web_capabilities_notice(&config, &capabilities), None);
717    }
718
719    /// The regression this gate exists to prevent: a WORKING cloud backend is
720    /// the case a sovereignty-focused tool most needs to disclose, so
721    /// viability alone must never buy silence.
722    #[test]
723    fn web_capability_notice_discloses_working_cloud_egress() {
724        let config = Config::default();
725        let capabilities = capabilities(
726            local_fetch(),
727            available(
728                "ollama_cloud",
729                "Ollama Cloud",
730                crate::providers::tool::web::Egress::OffMachine,
731            ),
732        );
733        let notice =
734            web_capabilities_notice(&config, &capabilities).expect("cloud egress must disclose");
735        assert!(
736            notice.contains("search: ollama_cloud (available; Ollama Cloud)"),
737            "{notice}"
738        );
739        // Nothing is broken, so nothing earns a remediation line.
740        assert!(!notice.contains('\n'), "{notice}");
741    }
742
743    /// An operator-supplied SearXNG URL cannot be proven to be loopback, so it
744    /// discloses like any other off-machine destination.
745    #[test]
746    fn web_capability_notice_discloses_configured_searxng_endpoint() {
747        let config = Config::default();
748        let capabilities = capabilities(
749            local_fetch(),
750            available(
751                "searxng",
752                "configured SearXNG instance",
753                crate::providers::tool::web::Egress::OffMachine,
754            ),
755        );
756        let notice =
757            web_capabilities_notice(&config, &capabilities).expect("configured endpoint discloses");
758        assert!(notice.contains("configured SearXNG instance"), "{notice}");
759    }
760
761    #[test]
762    fn web_capability_notice_gives_every_degraded_capability_its_own_line() {
763        let config = Config::default();
764        let capabilities = capabilities(
765            unavailable(
766                "native",
767                "direct from this machine",
768                crate::providers::tool::web::Egress::OnMachine,
769                "TLS backend failed to initialize",
770            ),
771            unavailable(
772                "managed_searxng",
773                "local managed process",
774                crate::providers::tool::web::Egress::OnMachine,
775                "no sovereign SearXNG bundle is available for this platform",
776            ),
777        );
778        let notice = web_capabilities_notice(&config, &capabilities).expect("both degraded");
779        let lines = notice.lines().collect::<Vec<_>>();
780        assert_eq!(lines.len(), 3, "{notice}");
781        assert!(lines[1].starts_with("- fetch: TLS backend"), "{notice}");
782        assert!(lines[2].starts_with("- search: no sovereign"), "{notice}");
783    }
784
785    #[test]
786    fn web_capability_notice_discloses_shared_backend_and_trust_routing() {
787        let config = Config::default();
788        let capabilities = capabilities(
789            local_fetch(),
790            unavailable(
791                "managed_searxng",
792                "local managed process",
793                crate::providers::tool::web::Egress::OnMachine,
794                "no sovereign SearXNG bundle is available for this platform",
795            ),
796        );
797        let notice = web_capabilities_notice(&config, &capabilities).expect("degraded search");
798        // The healthy capability still discloses where its traffic goes.
799        assert!(notice.contains("fetch: native (available"), "{notice}");
800        assert!(notice.contains("direct from this machine"), "{notice}");
801        assert!(
802            notice.contains("search: managed_searxng (unavailable)"),
803            "{notice}"
804        );
805    }
806
807    #[test]
808    fn web_capability_notice_moves_remediation_off_the_headline() {
809        let config = Config::default();
810        let capabilities = capabilities(
811            local_fetch(),
812            unavailable(
813                "managed_searxng",
814                "local managed process",
815                crate::providers::tool::web::Egress::OnMachine,
816                "no sovereign SearXNG bundle is available for this platform (windows/x86_64).\n  Configure `[web] search_backend = \"ollama\"`.",
817            ),
818        );
819        let notice = web_capabilities_notice(&config, &capabilities).expect("degraded search");
820        let (headline, detail) = notice.split_once('\n').expect("detail line");
821        // The unavailable backend routes nowhere, so its trust destination is
822        // not named — and the paragraph never lands mid-parenthetical.
823        assert!(!headline.contains("local managed process"), "{headline}");
824        assert!(!headline.contains("SearXNG bundle"), "{headline}");
825        assert_eq!(
826            detail,
827            "- search: no sovereign SearXNG bundle is available for this platform (windows/x86_64). Configure `[web] search_backend = \"ollama\"`."
828        );
829    }
830
831    #[test]
832    fn web_capability_notice_honors_global_network_denial() {
833        let mut config = Config::default();
834        config.safety.network = mermaid_domain::NetworkPolicy::Deny;
835        // Denial reports regardless of viability or locality — both backends
836        // resolve here, and both stay on this machine.
837        let capabilities = capabilities(local_fetch(), local_search());
838        let notice = web_capabilities_notice(&config, &capabilities).expect("denial always shows");
839        assert!(notice.contains("Web egress disabled"), "{notice}");
840        assert!(notice.contains("fetch backend: native"), "{notice}");
841        assert!(
842            notice.contains("search backend: managed_searxng"),
843            "{notice}"
844        );
845    }
846}