Skip to main content

mermaid_cli/app/
run.rs

1//! The ~30-line main loop.
2//!
3//! Single entry point that composes crossterm events, the reducer,
4//! and the effect runner:
5//!
6//! ```text
7//!   crossterm events ──┐
8//!                      ├── tokio::select! ── Msg ── update(State, Msg) ── (State, Vec<Cmd>) ── EffectRunner::dispatch ──┐
9//!   effect results  ──┤                                                                                                   │
10//!                      │                                                                          ▲                         │
11//!   tick              ──┘                                                                          │                         │
12//!                                                                                                  └─────── Msg back ◄──────┘
13//! ```
14//!
15//! No parallel event loops, no observer callbacks, no polling. One
16//! select!, one reducer call per message, effects dispatched into
17//! structured concurrency per turn.
18
19use std::collections::VecDeque;
20use std::path::PathBuf;
21
22use anyhow::Result;
23use crossterm::event::EventStream;
24use futures::{FutureExt, StreamExt};
25use ratatui::layout::Rect;
26use tokio::time::{Duration, interval};
27
28use crate::app::Config;
29use crate::app::event_source::coalesce_key_burst;
30use crate::app::lifecycle::RuntimeLifecycle;
31use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder, SessionHeader};
32use crate::app::terminal::TerminalGuard;
33use crate::domain::{Cmd, Msg, RuntimeSignal, State, update};
34use crate::effect::EffectRunner;
35use crate::providers::ToolRegistry;
36use crate::render::{RenderCache, render};
37use crate::session::ConversationHistory;
38
39/// Options for `run_interactive_with`. Added so new flags land without
40/// reshuffling positional args.
41///
42/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
43/// Debug. The bigger picture is that nothing prints these — they're an
44/// argument bundle, not telemetry.
45#[derive(Default)]
46pub struct InteractiveOptions {
47    /// Optional recorder for `--record <file>` JSONL capture.
48    pub recorder: Option<Recorder>,
49    /// Optional conversation to seed the session with (e.g. from
50    /// `--continue` or `--sessions`). When `Some`, the seeded history
51    /// replaces `State::session.conversation` before the first frame.
52    pub seed_conversation: Option<ConversationHistory>,
53}
54
55/// Interactive TUI main loop with explicit options. `recorder` (if
56/// provided) appends one JSONL line per reducer input to the file for
57/// debugging / replay.
58pub async fn run_interactive_with(
59    mut config: Config,
60    cwd: PathBuf,
61    model_id: String,
62    mut opts: InteractiveOptions,
63) -> Result<()> {
64    // One startup clock read, shared by `State::new` and the recording
65    // header: replay seeds `State::new` with the recorded value and gets the
66    // same initial conversation id/title.
67    let startup_now = chrono::Local::now();
68    // Fold enabled plugins' MCP servers + agent types into the merged config
69    // BEFORE anything consumes it (State::new seeds server rows, the
70    // recording header captures the merged config — replay-faithful, and the
71    // provider factory + tool registry see the same view).
72    let plugin_assets = crate::app::plugin_assets::load();
73    let plugin_warnings = crate::app::plugin_assets::apply(&mut config, &plugin_assets);
74    let mut state = State::new(config.clone(), cwd.clone(), model_id.clone(), startup_now);
75    let seed = opts.seed_conversation.take();
76    if let Some(r) = opts.recorder.as_mut() {
77        // The header makes a recording self-contained: `--replay` rebuilds
78        // the initial State from it (config, model, cwd, seed) without
79        // reading this machine's live config. Written before the first Msg
80        // so even a crashed session leaves a parseable log.
81        r.record_header(&SessionHeader {
82            format: RECORDING_FORMAT_VERSION,
83            ts: startup_now,
84            model_id: model_id.clone(),
85            cwd: cwd.clone(),
86            config: config.clone(),
87            seed_conversation: seed.clone(),
88        })?;
89    }
90    if let Some(history) = seed {
91        // `--continue` / `--resume` seed — shared with `--replay` via
92        // `State::seed_conversation` so both build the same starting state.
93        state.seed_conversation(history);
94    }
95    crate::app::stamp_session_provenance(&mut state, &cwd);
96    // NO_COLOR (https://no-color.org): present and non-empty disables all
97    // color. Read once here — the reducer never touches the environment; the
98    // render layer resolves `Theme::plain()` off this flag.
99    state.ui.no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
100    // Skills load once at startup (authored artifacts, no watcher); the config
101    // watcher below keeps only instructions/memory fresh.
102    state.skills = crate::app::skills::load(&cwd);
103    // Plugin prompt commands: same restart-to-refresh policy as skills.
104    state.plugin_commands = plugin_assets.commands;
105    for warning in plugin_warnings {
106        state
107            .ui
108            .pending_msgs
109            .push_back(Msg::TransientStatus { text: warning });
110    }
111    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
112    let tools = ToolRegistry::build(
113        &config,
114        crate::providers::TuiMode::Interactive,
115        providers.clone(),
116    );
117    if let Some(capabilities) = tools.web_capabilities() {
118        state.ui.pending_msgs.push_back(Msg::TransientStatus {
119            text: web_capabilities_notice(&config, capabilities),
120        });
121    }
122    let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
123    // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
124    // escalations) pause and prompt instead of erroring out, and inline
125    // `ask_user_question` prompts so the model can ask the user structured
126    // questions mid-run instead of proceeding without them.
127    let mut runner = runner
128        .with_interactive_approvals()
129        .with_interactive_questions();
130    // Keep instructions/memory fresh via the background config watcher (#45):
131    // it emits Msg::InstructionsChanged/MemoryChanged on change, so the reducer
132    // reads them as injected data and never does the refresh I/O inline.
133    runner.spawn_config_watcher(cwd.clone(), config.memory.clone());
134    let mut terminal = Some(TerminalGuard::setup()?);
135    let mut rstate = RenderCache::new();
136    // `Option` because the $EDITOR compose round-trip must DROP the stream
137    // (its reader thread holds crossterm's internal reader mutex) before
138    // suspending, and build a fresh one after — same lifecycle dance as
139    // `terminal` above.
140    let mut events = Some(EventStream::new());
141    let mut lifecycle = RuntimeLifecycle::new();
142    let mut tick = interval(Duration::from_millis(16));
143    let mut recorder = opts.recorder;
144
145    // Boot effects: MCP server init (if configured). Instructions/memory are
146    // loaded by the config watcher started above (#45), not here.
147    for cmd in bootstrap_cmds(&config, &state.session.conversation.id) {
148        runner.dispatch(cmd);
149    }
150    // A resumed session may carry an in-flight checklist; hand it to the
151    // TaskBroker (tool-side truth) so the first task tool call of the new
152    // process starts from the restored list instead of an empty one.
153    if !state.session.conversation.tasks.tasks.is_empty() {
154        runner.dispatch(crate::domain::Cmd::SyncTaskStore(
155            state.session.conversation.tasks.clone(),
156        ));
157    }
158
159    // Which `select!` arm fired. Terminal events are handled *after* the
160    // select! returns so the paste-coalescing drain can borrow `events`
161    // again without tripping the borrow checker.
162    //
163    // `Msg` is the large variant, but this enum lives on the stack for one
164    // loop iteration and `Msg` is passed by value everywhere already —
165    // boxing it would add a per-event heap alloc on the hot input path.
166    #[allow(clippy::large_enum_variant)]
167    enum Sel {
168        Msg(Option<Msg>),
169        Term(Option<Result<crossterm::event::Event, std::io::Error>>),
170    }
171
172    // Msgs produced ahead of time — e.g. a non-paste event drained while
173    // coalescing a key burst. Processed before pulling the next event.
174    let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
175
176    // Main loop. A fatal error inside the loop is captured here and returned
177    // AFTER the orderly-shutdown path below, so a draw failure can't skip MCP
178    // child cleanup / pending-save drains (the terminal is still restored by
179    // `TerminalGuard::Drop` regardless).
180    let mut exit_result: Result<()> = Ok(());
181    // Last-seen `full_redraw_seq`. When the reducer bumps it (shell command
182    // finished, Ctrl+L), `Terminal::clear()` resets ratatui's back buffer so
183    // the next draw repaints every cell — the only way to overwrite bytes
184    // some other process wrote directly to the tty (ghost cells).
185    let mut seen_redraw_seq = state.ui.full_redraw_seq;
186    loop {
187        // Render the current state. ratatui's draw closure captures
188        // &state, so we don't thread &mut state through the renderer.
189        {
190            let term = terminal
191                .as_mut()
192                .expect("terminal guard is alive while the render loop runs")
193                .inner_mut();
194            if state.ui.full_redraw_seq != seen_redraw_seq {
195                seen_redraw_seq = state.ui.full_redraw_seq;
196                // NOT `Terminal::clear()`: it snapshots the cursor with an
197                // ESC[6n round-trip, and the reply never arrives — the
198                // `EventStream` reader thread is parked holding crossterm's
199                // internal reader mutex and swallows it — so the query dies
200                // fatally after crossterm's 2s deadline. `resize()` to the
201                // current size performs the same full clear + back-buffer
202                // reset for a Fullscreen viewport without querying the tty.
203                let repaint = term
204                    .size()
205                    .and_then(|size| term.resize(Rect::new(0, 0, size.width, size.height)));
206                if let Err(err) = repaint {
207                    exit_result = Err(err.into());
208                    break;
209                }
210            }
211            if let Err(err) = term.draw(|f| render(&state, &mut rstate, f)) {
212                exit_result = Err(err.into());
213                break;
214            }
215        }
216
217        // Drain any msgs queued by a prior burst-coalesce before blocking
218        // on the next event.
219        let msg = if let Some(queued) = pending_msgs.pop_front() {
220            Some(queued)
221        } else {
222            let selected = tokio::select! {
223                // Fair (unbiased) polling. With `biased;`, the hot `msg_rx`
224                // arm would always win under sustained streaming and starve
225                // terminal input + OS signals (#112). Fair selection still
226                // drains streaming promptly — it's almost always ready — while
227                // guaranteeing the input/signal/tick arms get serviced too.
228                //
229                // Effect results (streaming chunks, tool output, …).
230                m = msg_rx.recv() => Sel::Msg(m),
231                // Crossterm events. Handled below, outside the select!, so
232                // coalescing can re-borrow `events`.
233                e = events.as_mut().expect("event stream is alive while the loop runs").next() => Sel::Term(e),
234                // OS lifecycle signals. A typed Ctrl+C in raw mode is handled
235                // by the crossterm branch above; this covers SIGINT/SIGTERM/
236                // SIGHUP delivered externally.
237                s = lifecycle.next_msg() => Sel::Msg(s),
238                // Tick — drives elapsed-time displays + self-dismissing status
239                // lines without busy-waiting.
240                _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
241            };
242
243            match selected {
244                Sel::Msg(m) => m,
245                Sel::Term(Some(Ok(evt))) => {
246                    if let crossterm::event::Event::Mouse(m) = &evt {
247                        use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
248                        let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
249                        match m.kind {
250                            // F13: Ctrl+Click a chat image tile opens it via
251                            // the system viewer. The screen→image mapping
252                            // lives in ChatState (the render layer).
253                            MEK::Down(MouseButton::Left) if ctrl => rstate
254                                .chat
255                                .find_image_at_screen_pos(m.row)
256                                .map(|target| Msg::OpenImageAt {
257                                    message_index: target.message_index,
258                                    image_index: target.image_index,
259                                    image_number: target.image_number,
260                                }),
261                            // Plain (no-modifier) left drag selects chat text.
262                            // Handled render-side so wheel-scroll + Ctrl+Click
263                            // keep working; on release we copy the selection.
264                            MEK::Down(MouseButton::Left) => {
265                                rstate.chat.begin_selection(m.row, m.column);
266                                None
267                            },
268                            MEK::Drag(MouseButton::Left) => {
269                                rstate.chat.update_selection(m.row, m.column);
270                                None
271                            },
272                            MEK::Up(MouseButton::Left) => {
273                                // A drag only *selects* (the highlight persists);
274                                // copying is an explicit action (Ctrl+Shift+C).
275                                // Auto-copying on release would silently clobber
276                                // the user's clipboard.
277                                None
278                            },
279                            MEK::ScrollUp => Some(Msg::MouseScroll {
280                                delta: crate::constants::UI_MOUSE_SCROLL_LINES as i16,
281                            }),
282                            MEK::ScrollDown => Some(Msg::MouseScroll {
283                                delta: -(crate::constants::UI_MOUSE_SCROLL_LINES as i16),
284                            }),
285                            _ => None,
286                        }
287                    } else {
288                        // Non-mouse event. Ctrl+Shift+C copies the current chat
289                        // selection — the explicit copy step after a drag-select.
290                        // Because the app holds the mouse, the terminal has no
291                        // selection of its own and passes the shortcut through.
292                        // The SHIFT bit only arrives when the kitty keyboard
293                        // protocol was negotiated at setup (TerminalGuard); on
294                        // legacy terminals Ctrl+Shift+C is transmitted as the
295                        // identical byte 0x03 as Ctrl+C — physically
296                        // indistinguishable — so there it falls through to the
297                        // reducer's Ctrl+C handling (press-twice-to-exit keeps
298                        // a stray copy-chord harmless).
299                        if let crossterm::event::Event::Key(k) = &evt
300                            && k.kind == crossterm::event::KeyEventKind::Press
301                            && k.modifiers
302                                .contains(crossterm::event::KeyModifiers::CONTROL)
303                            && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
304                            && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
305                        {
306                            // Route the copy through the reducer (#18): the
307                            // selection lives in the render layer, but emitting a
308                            // Msg keeps the clipboard side effect recorded +
309                            // replayable instead of dispatched out-of-band.
310                            rstate
311                                .chat
312                                .selected_text()
313                                .filter(|t| !t.is_empty())
314                                .map(Msg::CopySelection)
315                        } else {
316                            // Coalesce a paste burst (crossterm 0.29 doesn't
317                            // deliver Event::Paste on the Windows console — a
318                            // paste arrives as a flood of Char/Enter key events).
319                            // The drain pulls every immediately-available event
320                            // so the whole block lands as one atomic Msg::Paste.
321                            let (primary, trailing) = coalesce_key_burst(evt, || {
322                                events
323                                    .as_mut()
324                                    .expect("event stream is alive while the loop runs")
325                                    .next()
326                                    .now_or_never()
327                                    .flatten()
328                                    .and_then(|r| r.ok())
329                            });
330                            for queued in trailing {
331                                pending_msgs.push_back(queued);
332                            }
333                            primary
334                        }
335                    }
336                },
337                Sel::Term(Some(Err(error))) => {
338                    tracing::warn!(error = %error, "terminal event stream failed");
339                    None
340                },
341                Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
342            }
343        };
344
345        let Some(msg) = msg else { continue };
346
347        // Inject the wall clock as data (Cause 3): one stamp per tick, shared
348        // by the recording and the reducer. The recorded `ts` IS the
349        // `state.now` this Msg was reduced under, so `--replay` folds the
350        // same log by stamping each entry's `ts` here and recomputes the
351        // exact same states.
352        let now = chrono::Local::now();
353
354        // Optional recording: one JSONL line per Msg, before the
355        // reducer runs so the log captures even no-op inputs.
356        if let Some(r) = recorder.as_mut()
357            && let Err(err) = r.record_msg(now, &msg)
358        {
359            tracing::warn!(error = %err, "recorder: failed to record message; --replay may be non-deterministic");
360        }
361
362        state.now = now;
363        let (new_state, cmds) = update(state, msg);
364        state = new_state;
365        // `ComposeInEditor` is run-loop-owned (it suspends the terminal +
366        // event stream, which only this loop holds); everything else goes to
367        // the effect runner. At most one compose per reducer step by
368        // construction (single Ctrl+O / /editor arm).
369        let mut compose_draft: Option<String> = None;
370        for cmd in cmds {
371            if let Cmd::ComposeInEditor { text } = cmd {
372                compose_draft = Some(text);
373            } else {
374                runner.dispatch(cmd);
375            }
376        }
377        if let Some(draft) = compose_draft {
378            match crate::app::editor::compose_in_editor(&mut terminal, &mut events, draft).await {
379                // Through pending_msgs, so the result flows through the
380                // recorder like any input — --replay never launches an editor.
381                Ok(msg) => pending_msgs.push_back(msg),
382                Err(err) => {
383                    exit_result = Err(err);
384                    break;
385                },
386            }
387        }
388
389        if state.should_exit {
390            break;
391        }
392    }
393
394    // Seal the recording with a fingerprint of the final session, so a
395    // future `--replay` can verify its fold reproduces what this live
396    // session actually saw — not merely that the fold is self-consistent.
397    // (Wall-clock read is fine here: we're outside the reducer.)
398    if let Some(r) = recorder.as_mut()
399        && let Err(err) = r.record_trailer(chrono::Local::now(), &state.session)
400    {
401        tracing::warn!(error = %err, "recorder: failed to write replay trailer");
402    }
403
404    // Restore the user's terminal before async shutdown. Shutdown can
405    // wait on pending saves / cancelled scopes for a bounded period;
406    // keeping raw mode + mouse capture alive during that wait makes
407    // Ctrl+C feel ignored and can leak mouse escape sequences into
408    // the shell if the user keeps interacting.
409    drop(events);
410    if let Some(mut terminal) = terminal.take() {
411        terminal.restore_now();
412    }
413
414    // Orderly shutdown — wait for any pending saves / scope cleanup. Runs even
415    // when the loop broke on a draw error, so MCP children are reaped cleanly.
416    runner.shutdown().await;
417    exit_result
418}
419
420/// Commands dispatched on startup before the first iteration of the
421/// loop. Fires MCP init (if configured) and materializes the session's
422/// scratch directory. Instructions/memory are loaded by the config
423/// watcher (#45), not here.
424fn bootstrap_cmds(config: &Config, session_id: &str) -> Vec<Cmd> {
425    // Instructions/memory load + stay fresh via the config watcher (#45),
426    // started in `run_interactive_with`.
427    let mut cmds = Vec::new();
428    if !config.mcp_servers.is_empty() {
429        cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
430    }
431    // Every session gets a scratch dir — `session_id` is captured AFTER any
432    // `--continue`/`--resume` seed, so a resumed session adopts the dir
433    // keyed by its restored conversation id.
434    cmds.push(Cmd::EnsureScratchpad {
435        session_id: session_id.to_string(),
436    });
437    cmds
438}
439
440/// One startup-visible summary built from the exact capability resolution used
441/// by the registry and subagents. This makes backend/trust routing explicit in
442/// the TUI without re-reading credentials or probing platform viability.
443fn web_capabilities_notice(
444    config: &Config,
445    capabilities: &crate::providers::tool::web::WebCapabilities,
446) -> String {
447    if config.safety.network == crate::app::NetworkPolicy::Deny {
448        return format!(
449            "Web egress disabled by safety.network = \"deny\" (selected fetch backend: {}; selected search backend: {}).",
450            capabilities.fetch.backend, capabilities.search.backend
451        );
452    }
453
454    let render = |name: &str, status: &crate::providers::tool::web::WebCapabilityStatus| {
455        let availability = if status.available {
456            "available".to_string()
457        } else {
458            let reason = status
459                .reason
460                .as_deref()
461                .map(crate::utils::redact_secrets)
462                .unwrap_or_else(|| "backend initialization failed".to_string());
463            let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
464            let reason = crate::utils::truncate_middle_bytes(&reason, 240)
465                .split_whitespace()
466                .collect::<Vec<_>>()
467                .join(" ");
468            format!("unavailable: {reason}")
469        };
470        format!(
471            "{name}: {} ({availability}; {})",
472            status.backend, status.trust_destination
473        )
474    };
475
476    format!(
477        "Web capabilities - {}; {}.",
478        render("fetch", &capabilities.fetch),
479        render("search", &capabilities.search)
480    )
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn bootstrap_always_ensures_the_session_scratchpad() {
489        // Instructions/memory load via the config watcher (#45), not
490        // bootstrap; with no MCP servers configured, only the scratchpad
491        // ensure remains — keyed by the caller's session id.
492        let cmds = bootstrap_cmds(&Config::default(), "sess-1");
493        assert_eq!(cmds.len(), 1);
494        assert!(
495            cmds.iter().any(
496                |c| matches!(c, Cmd::EnsureScratchpad { session_id } if session_id == "sess-1")
497            )
498        );
499    }
500
501    #[test]
502    fn bootstrap_skips_mcp_init_when_no_servers_configured() {
503        let cmds = bootstrap_cmds(&Config::default(), "sess-1");
504        assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
505    }
506
507    #[test]
508    fn bootstrap_includes_mcp_init_when_servers_configured() {
509        let mut cfg = Config::default();
510        cfg.mcp_servers.insert(
511            "example".to_string(),
512            crate::app::McpServerConfig {
513                command: "echo".to_string(),
514                args: vec![],
515                env: std::collections::HashMap::new(),
516                ..Default::default()
517            },
518        );
519        let cmds = bootstrap_cmds(&cfg, "sess-1");
520        assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
521    }
522
523    #[test]
524    fn web_capability_notice_discloses_shared_backend_and_trust_routing() {
525        let config = Config::default();
526        let capabilities = crate::providers::tool::web::WebCapabilities::resolve(&config.web);
527        let notice = web_capabilities_notice(&config, &capabilities);
528        assert!(notice.contains("fetch: native"), "{notice}");
529        assert!(notice.contains("direct from this machine"), "{notice}");
530        assert!(notice.contains("search: managed_searxng"), "{notice}");
531        assert!(notice.contains("local managed process"), "{notice}");
532    }
533
534    #[test]
535    fn web_capability_notice_honors_global_network_denial() {
536        let mut config = Config::default();
537        config.safety.network = crate::app::NetworkPolicy::Deny;
538        let capabilities = crate::providers::tool::web::WebCapabilities::resolve(&config.web);
539        let notice = web_capabilities_notice(&config, &capabilities);
540        assert!(notice.contains("Web egress disabled"), "{notice}");
541        assert!(notice.contains("fetch backend: native"), "{notice}");
542        assert!(
543            notice.contains("search backend: managed_searxng"),
544            "{notice}"
545        );
546    }
547}