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::path::PathBuf;
20
21use anyhow::Result;
22use crossterm::event::EventStream;
23use futures::StreamExt;
24use tokio::time::{Duration, interval};
25
26use crate::app::Config;
27use crate::app::event_source::event_to_msg;
28use crate::app::lifecycle::RuntimeLifecycle;
29use crate::app::recorder::{Recorder, record_msg_body};
30use crate::app::terminal::TerminalGuard;
31use crate::domain::{Cmd, Msg, RuntimeSignal, State, update};
32use crate::effect::EffectRunner;
33use crate::providers::ToolRegistry;
34use crate::render::{RenderCache, render};
35use crate::session::ConversationHistory;
36
37/// Options for `run_interactive`. Added so new flags land without
38/// reshuffling positional args.
39///
40/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
41/// Debug. The bigger picture is that nothing prints these — they're an
42/// argument bundle, not telemetry.
43#[derive(Default)]
44pub struct InteractiveOptions {
45    /// Optional recorder for `--record <file>` JSONL replay.
46    pub recorder: Option<Recorder>,
47    /// Optional conversation to seed the session with (e.g. from
48    /// `--continue` or `--sessions`). When `Some`, the seeded history
49    /// replaces `State::session.conversation` before the first frame.
50    pub seed_conversation: Option<ConversationHistory>,
51}
52
53/// Interactive TUI main loop. Backwards-compatible wrapper that
54/// forwards to `run_interactive_with` with default options.
55pub async fn run_interactive(
56    config: Config,
57    cwd: PathBuf,
58    model_id: String,
59    recorder: Option<Recorder>,
60) -> Result<()> {
61    run_interactive_with(
62        config,
63        cwd,
64        model_id,
65        InteractiveOptions {
66            recorder,
67            seed_conversation: None,
68        },
69    )
70    .await
71}
72
73/// Interactive TUI main loop with explicit options. `recorder` (if
74/// provided) appends one JSONL line per reducer input to the file for
75/// debugging / replay.
76pub async fn run_interactive_with(
77    config: Config,
78    cwd: PathBuf,
79    model_id: String,
80    mut opts: InteractiveOptions,
81) -> Result<()> {
82    let mut state = State::new(config.clone(), cwd.clone(), model_id);
83    if let Some(history) = opts.seed_conversation.take() {
84        // `--continue` / `--sessions` seed: replace the fresh
85        // conversation with the loaded history. Title already reflects
86        // the saved session, so re-dispatch the terminal title once.
87        let title = history.title.clone();
88        state.session.conversation = history;
89        state.ui.last_title_dispatched = Some(title);
90    }
91    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
92    let tools = ToolRegistry::build(
93        &config,
94        crate::providers::TuiMode::Interactive,
95        providers.clone(),
96    );
97    let (mut runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
98    let mut terminal = Some(TerminalGuard::setup()?);
99    let mut rstate = RenderCache::new();
100    let mut events = EventStream::new();
101    let mut lifecycle = RuntimeLifecycle::new();
102    let mut tick = interval(Duration::from_millis(16));
103    let mut recorder = opts.recorder;
104
105    // Boot effects: MCP server init (if configured) + an initial
106    // instructions refresh so MERMAID.md content is in State before
107    // the first prompt.
108    for cmd in bootstrap_cmds(&config) {
109        runner.dispatch(cmd);
110    }
111
112    // Main loop.
113    loop {
114        // Render the current state. ratatui's draw closure captures
115        // &state, so we don't thread &mut state through the renderer.
116        terminal
117            .as_mut()
118            .expect("terminal guard is alive while the render loop runs")
119            .inner_mut()
120            .draw(|f| render(&state, &mut rstate, f))?;
121
122        let msg = tokio::select! {
123            biased;
124            // 1. Effect results first. Streaming chunks are hot; we
125            //    want render latency low when the model is producing
126            //    tokens.
127            m = msg_rx.recv() => m,
128            // 2. Crossterm events.
129            e = events.next() => match e {
130                Some(Ok(evt)) => {
131                    // F13: Ctrl+Click on a chat image tile opens the
132                    // image via the system viewer. Mapping from screen
133                    // coords to (message_index, image_index) lives in
134                    // ChatState (the render layer) — the event source
135                    // can't do it alone. Synthesize `OpenImageAt` here
136                    // when the click hits a tracked image.
137                    if let crossterm::event::Event::Mouse(m) = &evt
138                        && matches!(m.kind, crossterm::event::MouseEventKind::Down(_))
139                        && m.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
140                        && let Some(target) = rstate.chat.find_image_at_screen_pos(m.row)
141                    {
142                        Some(Msg::OpenImageAt {
143                            message_index: target.message_index,
144                            image_index: target.image_index,
145                        })
146                    } else {
147                        event_to_msg(evt)
148                    }
149                },
150                Some(Err(error)) => {
151                    tracing::warn!(error = %error, "terminal event stream failed");
152                    None
153                },
154                None => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
155            },
156            // 3. OS lifecycle signals. A typed Ctrl+C in raw mode is
157            //    handled by the crossterm branch above; this covers
158            //    SIGINT/SIGTERM/SIGHUP delivered externally.
159            s = lifecycle.next_msg() => s,
160            // 4. Tick — drives elapsed-time displays + self-dismissing
161            //    status lines without busy-waiting.
162            _ = tick.tick() => Some(Msg::Tick),
163        };
164
165        let Some(msg) = msg else { continue };
166
167        // Optional recording: one JSONL line per Msg, before the
168        // reducer runs so the log captures even no-op inputs.
169        if let Some(r) = recorder.as_mut() {
170            let body = record_msg_body(&msg);
171            let _ = r.record_kind(msg.kind(), msg.turn_id(), body);
172        }
173
174        let (new_state, cmds) = update(state, msg);
175        state = new_state;
176        for cmd in cmds {
177            runner.dispatch(cmd);
178        }
179
180        if state.should_exit {
181            break;
182        }
183    }
184
185    // Restore the user's terminal before async shutdown. Shutdown can
186    // wait on pending saves / cancelled scopes for a bounded period;
187    // keeping raw mode + mouse capture alive during that wait makes
188    // Ctrl+C feel ignored and can leak mouse escape sequences into
189    // the shell if the user keeps interacting.
190    drop(events);
191    if let Some(mut terminal) = terminal.take() {
192        terminal.restore_now();
193    }
194
195    // Orderly shutdown — wait for any pending saves / scope cleanup.
196    runner.shutdown().await;
197    Ok(())
198}
199
200/// Commands dispatched on startup before the first iteration of the
201/// loop. Fires MCP init (if configured) + an initial instructions
202/// sweep so MERMAID.md content lands before the first prompt.
203fn bootstrap_cmds(config: &Config) -> Vec<Cmd> {
204    let mut cmds = vec![Cmd::RefreshInstructions];
205    if !config.mcp_servers.is_empty() {
206        cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
207    }
208    cmds
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn bootstrap_includes_refresh_instructions() {
217        let cmds = bootstrap_cmds(&Config::default());
218        assert!(cmds.iter().any(|c| matches!(c, Cmd::RefreshInstructions)));
219    }
220
221    #[test]
222    fn bootstrap_skips_mcp_init_when_no_servers_configured() {
223        let cmds = bootstrap_cmds(&Config::default());
224        assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
225    }
226
227    #[test]
228    fn bootstrap_includes_mcp_init_when_servers_configured() {
229        let mut cfg = Config::default();
230        cfg.mcp_servers.insert(
231            "example".to_string(),
232            crate::app::McpServerConfig {
233                command: "echo".to_string(),
234                args: vec![],
235                env: std::collections::HashMap::new(),
236            },
237        );
238        let cmds = bootstrap_cmds(&cfg);
239        assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
240    }
241}