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 tokio::time::{Duration, interval};
26
27use crate::app::Config;
28use crate::app::event_source::coalesce_key_burst;
29use crate::app::lifecycle::RuntimeLifecycle;
30use crate::app::recorder::{Recorder, record_msg_body};
31use crate::app::terminal::TerminalGuard;
32use crate::domain::{Cmd, Msg, RuntimeSignal, State, update};
33use crate::effect::EffectRunner;
34use crate::providers::ToolRegistry;
35use crate::render::{RenderCache, render};
36use crate::session::ConversationHistory;
37
38/// Options for `run_interactive`. Added so new flags land without
39/// reshuffling positional args.
40///
41/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
42/// Debug. The bigger picture is that nothing prints these — they're an
43/// argument bundle, not telemetry.
44#[derive(Default)]
45pub struct InteractiveOptions {
46    /// Optional recorder for `--record <file>` JSONL replay.
47    pub recorder: Option<Recorder>,
48    /// Optional conversation to seed the session with (e.g. from
49    /// `--continue` or `--sessions`). When `Some`, the seeded history
50    /// replaces `State::session.conversation` before the first frame.
51    pub seed_conversation: Option<ConversationHistory>,
52}
53
54/// Interactive TUI main loop. Backwards-compatible wrapper that
55/// forwards to `run_interactive_with` with default options.
56pub async fn run_interactive(
57    config: Config,
58    cwd: PathBuf,
59    model_id: String,
60    recorder: Option<Recorder>,
61) -> Result<()> {
62    run_interactive_with(
63        config,
64        cwd,
65        model_id,
66        InteractiveOptions {
67            recorder,
68            seed_conversation: None,
69        },
70    )
71    .await
72}
73
74/// Interactive TUI main loop with explicit options. `recorder` (if
75/// provided) appends one JSONL line per reducer input to the file for
76/// debugging / replay.
77pub async fn run_interactive_with(
78    config: Config,
79    cwd: PathBuf,
80    model_id: String,
81    mut opts: InteractiveOptions,
82) -> Result<()> {
83    let mut state = State::new(config.clone(), cwd.clone(), model_id);
84    if let Some(history) = opts.seed_conversation.take() {
85        // `--continue` / `--sessions` seed: replace the fresh
86        // conversation with the loaded history. Title already reflects
87        // the saved session, so re-dispatch the terminal title once.
88        let title = history.title.clone();
89        state.session.conversation = history;
90        state.ui.last_title_dispatched = Some(title);
91    }
92    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
93    let tools = ToolRegistry::build(
94        &config,
95        crate::providers::TuiMode::Interactive,
96        providers.clone(),
97    );
98    let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
99    // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
100    // escalations) pause and prompt instead of erroring out.
101    let mut runner = runner.with_interactive_approvals();
102    let mut terminal = Some(TerminalGuard::setup()?);
103    let mut rstate = RenderCache::new();
104    let mut events = EventStream::new();
105    let mut lifecycle = RuntimeLifecycle::new();
106    let mut tick = interval(Duration::from_millis(16));
107    let mut recorder = opts.recorder;
108
109    // Boot effects: MCP server init (if configured) + an initial
110    // instructions refresh so MERMAID.md content is in State before
111    // the first prompt.
112    for cmd in bootstrap_cmds(&config) {
113        runner.dispatch(cmd);
114    }
115
116    // Which `select!` arm fired. Terminal events are handled *after* the
117    // select! returns so the paste-coalescing drain can borrow `events`
118    // again without tripping the borrow checker.
119    //
120    // `Msg` is the large variant, but this enum lives on the stack for one
121    // loop iteration and `Msg` is passed by value everywhere already —
122    // boxing it would add a per-event heap alloc on the hot input path.
123    #[allow(clippy::large_enum_variant)]
124    enum Sel {
125        Msg(Option<Msg>),
126        Term(Option<Result<crossterm::event::Event, std::io::Error>>),
127    }
128
129    // Msgs produced ahead of time — e.g. a non-paste event drained while
130    // coalescing a key burst. Processed before pulling the next event.
131    let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
132
133    // Main loop.
134    loop {
135        // Render the current state. ratatui's draw closure captures
136        // &state, so we don't thread &mut state through the renderer.
137        terminal
138            .as_mut()
139            .expect("terminal guard is alive while the render loop runs")
140            .inner_mut()
141            .draw(|f| render(&state, &mut rstate, f))?;
142
143        // Drain any msgs queued by a prior burst-coalesce before blocking
144        // on the next event.
145        let msg = if let Some(queued) = pending_msgs.pop_front() {
146            Some(queued)
147        } else {
148            let selected = tokio::select! {
149                biased;
150                // 1. Effect results first. Streaming chunks are hot; we
151                //    want render latency low when the model is producing
152                //    tokens.
153                m = msg_rx.recv() => Sel::Msg(m),
154                // 2. Crossterm events. Handled below, outside the select!,
155                //    so coalescing can re-borrow `events`.
156                e = events.next() => Sel::Term(e),
157                // 3. OS lifecycle signals. A typed Ctrl+C in raw mode is
158                //    handled by the crossterm branch above; this covers
159                //    SIGINT/SIGTERM/SIGHUP delivered externally.
160                s = lifecycle.next_msg() => Sel::Msg(s),
161                // 4. Tick — drives elapsed-time displays + self-dismissing
162                //    status lines without busy-waiting.
163                _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
164            };
165
166            match selected {
167                Sel::Msg(m) => m,
168                Sel::Term(Some(Ok(evt))) => {
169                    if let crossterm::event::Event::Mouse(m) = &evt {
170                        use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
171                        let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
172                        match m.kind {
173                            // F13: Ctrl+Click a chat image tile opens it via
174                            // the system viewer. The screen→image mapping
175                            // lives in ChatState (the render layer).
176                            MEK::Down(MouseButton::Left) if ctrl => rstate
177                                .chat
178                                .find_image_at_screen_pos(m.row)
179                                .map(|target| Msg::OpenImageAt {
180                                    message_index: target.message_index,
181                                    image_index: target.image_index,
182                                }),
183                            // Plain (no-modifier) left drag selects chat text.
184                            // Handled render-side so wheel-scroll + Ctrl+Click
185                            // keep working; on release we copy the selection.
186                            MEK::Down(MouseButton::Left) => {
187                                rstate.chat.begin_selection(m.row, m.column);
188                                None
189                            },
190                            MEK::Drag(MouseButton::Left) => {
191                                rstate.chat.update_selection(m.row, m.column);
192                                None
193                            },
194                            MEK::Up(MouseButton::Left) => {
195                                // A drag only *selects* (the highlight persists);
196                                // copying is an explicit action (Ctrl+Shift+C).
197                                // Auto-copying on release would silently clobber
198                                // the user's clipboard.
199                                None
200                            },
201                            MEK::ScrollUp => Some(Msg::MouseScroll {
202                                delta: crate::constants::UI_MOUSE_SCROLL_LINES as i16,
203                            }),
204                            MEK::ScrollDown => Some(Msg::MouseScroll {
205                                delta: -(crate::constants::UI_MOUSE_SCROLL_LINES as i16),
206                            }),
207                            _ => None,
208                        }
209                    } else {
210                        // Non-mouse event. Ctrl+Shift+C copies the current chat
211                        // selection — the explicit copy step after a drag-select.
212                        // Because the app holds the mouse, the terminal has no
213                        // selection of its own and passes the shortcut through.
214                        if let crossterm::event::Event::Key(k) = &evt
215                            && k.kind == crossterm::event::KeyEventKind::Press
216                            && k.modifiers
217                                .contains(crossterm::event::KeyModifiers::CONTROL)
218                            && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
219                            && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
220                        {
221                            if let Some(text) = rstate.chat.selected_text()
222                                && !text.is_empty()
223                            {
224                                runner.dispatch(Cmd::CopyToClipboard(text));
225                            }
226                            None
227                        } else {
228                            // Coalesce a paste burst (crossterm 0.29 doesn't
229                            // deliver Event::Paste on the Windows console — a
230                            // paste arrives as a flood of Char/Enter key events).
231                            // The drain pulls every immediately-available event
232                            // so the whole block lands as one atomic Msg::Paste.
233                            let (primary, trailing) = coalesce_key_burst(evt, || {
234                                events.next().now_or_never().flatten().and_then(|r| r.ok())
235                            });
236                            for queued in trailing {
237                                pending_msgs.push_back(queued);
238                            }
239                            primary
240                        }
241                    }
242                },
243                Sel::Term(Some(Err(error))) => {
244                    tracing::warn!(error = %error, "terminal event stream failed");
245                    None
246                },
247                Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
248            }
249        };
250
251        let Some(msg) = msg else { continue };
252
253        // Optional recording: one JSONL line per Msg, before the
254        // reducer runs so the log captures even no-op inputs.
255        if let Some(r) = recorder.as_mut() {
256            let body = record_msg_body(&msg);
257            let _ = r.record_kind(msg.kind(), msg.turn_id(), body);
258        }
259
260        let (new_state, cmds) = update(state, msg);
261        state = new_state;
262        for cmd in cmds {
263            runner.dispatch(cmd);
264        }
265
266        if state.should_exit {
267            break;
268        }
269    }
270
271    // Restore the user's terminal before async shutdown. Shutdown can
272    // wait on pending saves / cancelled scopes for a bounded period;
273    // keeping raw mode + mouse capture alive during that wait makes
274    // Ctrl+C feel ignored and can leak mouse escape sequences into
275    // the shell if the user keeps interacting.
276    drop(events);
277    if let Some(mut terminal) = terminal.take() {
278        terminal.restore_now();
279    }
280
281    // Orderly shutdown — wait for any pending saves / scope cleanup.
282    runner.shutdown().await;
283    Ok(())
284}
285
286/// Commands dispatched on startup before the first iteration of the
287/// loop. Fires MCP init (if configured) + an initial instructions
288/// sweep so MERMAID.md content lands before the first prompt.
289fn bootstrap_cmds(config: &Config) -> Vec<Cmd> {
290    let mut cmds = vec![Cmd::RefreshInstructions, Cmd::RefreshMemory];
291    if !config.mcp_servers.is_empty() {
292        cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
293    }
294    cmds
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn bootstrap_includes_refresh_instructions() {
303        let cmds = bootstrap_cmds(&Config::default());
304        assert!(cmds.iter().any(|c| matches!(c, Cmd::RefreshInstructions)));
305    }
306
307    #[test]
308    fn bootstrap_includes_refresh_memory() {
309        let cmds = bootstrap_cmds(&Config::default());
310        assert!(cmds.iter().any(|c| matches!(c, Cmd::RefreshMemory)));
311    }
312
313    #[test]
314    fn bootstrap_skips_mcp_init_when_no_servers_configured() {
315        let cmds = bootstrap_cmds(&Config::default());
316        assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
317    }
318
319    #[test]
320    fn bootstrap_includes_mcp_init_when_servers_configured() {
321        let mut cfg = Config::default();
322        cfg.mcp_servers.insert(
323            "example".to_string(),
324            crate::app::McpServerConfig {
325                command: "echo".to_string(),
326                args: vec![],
327                env: std::collections::HashMap::new(),
328            },
329        );
330        let cmds = bootstrap_cmds(&cfg);
331        assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
332    }
333}