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::{RECORDING_FORMAT_VERSION, Recorder, SessionHeader};
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_with`. 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 capture.
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 with explicit options. `recorder` (if
55/// provided) appends one JSONL line per reducer input to the file for
56/// debugging / replay.
57pub async fn run_interactive_with(
58    config: Config,
59    cwd: PathBuf,
60    model_id: String,
61    mut opts: InteractiveOptions,
62) -> Result<()> {
63    // One startup clock read, shared by `State::new` and the recording
64    // header: replay seeds `State::new` with the recorded value and gets the
65    // same initial conversation id/title.
66    let startup_now = chrono::Local::now();
67    let mut state = State::new(config.clone(), cwd.clone(), model_id.clone(), startup_now);
68    let seed = opts.seed_conversation.take();
69    if let Some(r) = opts.recorder.as_mut() {
70        // The header makes a recording self-contained: `--replay` rebuilds
71        // the initial State from it (config, model, cwd, seed) without
72        // reading this machine's live config. Written before the first Msg
73        // so even a crashed session leaves a parseable log.
74        r.record_header(&SessionHeader {
75            format: RECORDING_FORMAT_VERSION,
76            ts: startup_now,
77            model_id: model_id.clone(),
78            cwd: cwd.clone(),
79            config: config.clone(),
80            seed_conversation: seed.clone(),
81        })?;
82    }
83    if let Some(history) = seed {
84        // `--continue` / `--resume` seed — shared with `--replay` via
85        // `State::seed_conversation` so both build the same starting state.
86        state.seed_conversation(history);
87    }
88    // Stamp the git branch for the `--resume` picker. Done here (impure
89    // startup) rather than in `ConversationHistory::new` so the reducer stays
90    // deterministic for `--replay`. Only fills a blank — a resumed session
91    // keeps the branch it was saved with; an older session with no stored
92    // branch gets backfilled on its next save.
93    if state.session.conversation.git_branch.is_none() {
94        state.session.conversation.git_branch = crate::session::detect_git_branch(&cwd);
95    }
96    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
97    let tools = ToolRegistry::build(
98        &config,
99        crate::providers::TuiMode::Interactive,
100        providers.clone(),
101    );
102    let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
103    // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
104    // escalations) pause and prompt instead of erroring out.
105    let mut runner = runner.with_interactive_approvals();
106    // Keep instructions/memory fresh via the background config watcher (#45):
107    // it emits Msg::InstructionsChanged/MemoryChanged on change, so the reducer
108    // reads them as injected data and never does the refresh I/O inline.
109    runner.spawn_config_watcher(cwd.clone(), config.memory.clone());
110    let mut terminal = Some(TerminalGuard::setup()?);
111    let mut rstate = RenderCache::new();
112    let mut events = EventStream::new();
113    let mut lifecycle = RuntimeLifecycle::new();
114    let mut tick = interval(Duration::from_millis(16));
115    let mut recorder = opts.recorder;
116
117    // Boot effects: MCP server init (if configured). Instructions/memory are
118    // loaded by the config watcher started above (#45), not here.
119    for cmd in bootstrap_cmds(&config) {
120        runner.dispatch(cmd);
121    }
122
123    // Which `select!` arm fired. Terminal events are handled *after* the
124    // select! returns so the paste-coalescing drain can borrow `events`
125    // again without tripping the borrow checker.
126    //
127    // `Msg` is the large variant, but this enum lives on the stack for one
128    // loop iteration and `Msg` is passed by value everywhere already —
129    // boxing it would add a per-event heap alloc on the hot input path.
130    #[allow(clippy::large_enum_variant)]
131    enum Sel {
132        Msg(Option<Msg>),
133        Term(Option<Result<crossterm::event::Event, std::io::Error>>),
134    }
135
136    // Msgs produced ahead of time — e.g. a non-paste event drained while
137    // coalescing a key burst. Processed before pulling the next event.
138    let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
139
140    // Main loop. A fatal error inside the loop is captured here and returned
141    // AFTER the orderly-shutdown path below, so a draw failure can't skip MCP
142    // child cleanup / pending-save drains (the terminal is still restored by
143    // `TerminalGuard::Drop` regardless).
144    let mut exit_result: Result<()> = Ok(());
145    loop {
146        // Render the current state. ratatui's draw closure captures
147        // &state, so we don't thread &mut state through the renderer.
148        if let Err(err) = terminal
149            .as_mut()
150            .expect("terminal guard is alive while the render loop runs")
151            .inner_mut()
152            .draw(|f| render(&state, &mut rstate, f))
153        {
154            exit_result = Err(err.into());
155            break;
156        }
157
158        // Drain any msgs queued by a prior burst-coalesce before blocking
159        // on the next event.
160        let msg = if let Some(queued) = pending_msgs.pop_front() {
161            Some(queued)
162        } else {
163            let selected = tokio::select! {
164                // Fair (unbiased) polling. With `biased;`, the hot `msg_rx`
165                // arm would always win under sustained streaming and starve
166                // terminal input + OS signals (#112). Fair selection still
167                // drains streaming promptly — it's almost always ready — while
168                // guaranteeing the input/signal/tick arms get serviced too.
169                //
170                // Effect results (streaming chunks, tool output, …).
171                m = msg_rx.recv() => Sel::Msg(m),
172                // Crossterm events. Handled below, outside the select!, so
173                // coalescing can re-borrow `events`.
174                e = events.next() => Sel::Term(e),
175                // OS lifecycle signals. A typed Ctrl+C in raw mode is handled
176                // by the crossterm branch above; this covers SIGINT/SIGTERM/
177                // SIGHUP delivered externally.
178                s = lifecycle.next_msg() => Sel::Msg(s),
179                // Tick — drives elapsed-time displays + self-dismissing status
180                // lines without busy-waiting.
181                _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
182            };
183
184            match selected {
185                Sel::Msg(m) => m,
186                Sel::Term(Some(Ok(evt))) => {
187                    if let crossterm::event::Event::Mouse(m) = &evt {
188                        use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
189                        let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
190                        match m.kind {
191                            // F13: Ctrl+Click a chat image tile opens it via
192                            // the system viewer. The screen→image mapping
193                            // lives in ChatState (the render layer).
194                            MEK::Down(MouseButton::Left) if ctrl => rstate
195                                .chat
196                                .find_image_at_screen_pos(m.row)
197                                .map(|target| Msg::OpenImageAt {
198                                    message_index: target.message_index,
199                                    image_index: target.image_index,
200                                }),
201                            // Plain (no-modifier) left drag selects chat text.
202                            // Handled render-side so wheel-scroll + Ctrl+Click
203                            // keep working; on release we copy the selection.
204                            MEK::Down(MouseButton::Left) => {
205                                rstate.chat.begin_selection(m.row, m.column);
206                                None
207                            },
208                            MEK::Drag(MouseButton::Left) => {
209                                rstate.chat.update_selection(m.row, m.column);
210                                None
211                            },
212                            MEK::Up(MouseButton::Left) => {
213                                // A drag only *selects* (the highlight persists);
214                                // copying is an explicit action (Ctrl+Shift+C).
215                                // Auto-copying on release would silently clobber
216                                // the user's clipboard.
217                                None
218                            },
219                            MEK::ScrollUp => Some(Msg::MouseScroll {
220                                delta: crate::constants::UI_MOUSE_SCROLL_LINES as i16,
221                            }),
222                            MEK::ScrollDown => Some(Msg::MouseScroll {
223                                delta: -(crate::constants::UI_MOUSE_SCROLL_LINES as i16),
224                            }),
225                            _ => None,
226                        }
227                    } else {
228                        // Non-mouse event. Ctrl+Shift+C copies the current chat
229                        // selection — the explicit copy step after a drag-select.
230                        // Because the app holds the mouse, the terminal has no
231                        // selection of its own and passes the shortcut through.
232                        if let crossterm::event::Event::Key(k) = &evt
233                            && k.kind == crossterm::event::KeyEventKind::Press
234                            && k.modifiers
235                                .contains(crossterm::event::KeyModifiers::CONTROL)
236                            && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
237                            && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
238                        {
239                            // Route the copy through the reducer (#18): the
240                            // selection lives in the render layer, but emitting a
241                            // Msg keeps the clipboard side effect recorded +
242                            // replayable instead of dispatched out-of-band.
243                            rstate
244                                .chat
245                                .selected_text()
246                                .filter(|t| !t.is_empty())
247                                .map(Msg::CopySelection)
248                        } else {
249                            // Coalesce a paste burst (crossterm 0.29 doesn't
250                            // deliver Event::Paste on the Windows console — a
251                            // paste arrives as a flood of Char/Enter key events).
252                            // The drain pulls every immediately-available event
253                            // so the whole block lands as one atomic Msg::Paste.
254                            let (primary, trailing) = coalesce_key_burst(evt, || {
255                                events.next().now_or_never().flatten().and_then(|r| r.ok())
256                            });
257                            for queued in trailing {
258                                pending_msgs.push_back(queued);
259                            }
260                            primary
261                        }
262                    }
263                },
264                Sel::Term(Some(Err(error))) => {
265                    tracing::warn!(error = %error, "terminal event stream failed");
266                    None
267                },
268                Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
269            }
270        };
271
272        let Some(msg) = msg else { continue };
273
274        // Inject the wall clock as data (Cause 3): one stamp per tick, shared
275        // by the recording and the reducer. The recorded `ts` IS the
276        // `state.now` this Msg was reduced under, so `--replay` folds the
277        // same log by stamping each entry's `ts` here and recomputes the
278        // exact same states.
279        let now = chrono::Local::now();
280
281        // Optional recording: one JSONL line per Msg, before the
282        // reducer runs so the log captures even no-op inputs.
283        if let Some(r) = recorder.as_mut() {
284            let _ = r.record_msg(now, &msg);
285        }
286
287        state.now = now;
288        let (new_state, cmds) = update(state, msg);
289        state = new_state;
290        for cmd in cmds {
291            runner.dispatch(cmd);
292        }
293
294        if state.should_exit {
295            break;
296        }
297    }
298
299    // Seal the recording with a fingerprint of the final session, so a
300    // future `--replay` can verify its fold reproduces what this live
301    // session actually saw — not merely that the fold is self-consistent.
302    // (Wall-clock read is fine here: we're outside the reducer.)
303    if let Some(r) = recorder.as_mut() {
304        let _ = r.record_trailer(chrono::Local::now(), &state.session);
305    }
306
307    // Restore the user's terminal before async shutdown. Shutdown can
308    // wait on pending saves / cancelled scopes for a bounded period;
309    // keeping raw mode + mouse capture alive during that wait makes
310    // Ctrl+C feel ignored and can leak mouse escape sequences into
311    // the shell if the user keeps interacting.
312    drop(events);
313    if let Some(mut terminal) = terminal.take() {
314        terminal.restore_now();
315    }
316
317    // Orderly shutdown — wait for any pending saves / scope cleanup. Runs even
318    // when the loop broke on a draw error, so MCP children are reaped cleanly.
319    runner.shutdown().await;
320    exit_result
321}
322
323/// Commands dispatched on startup before the first iteration of the
324/// loop. Fires MCP init (if configured). Instructions/memory are loaded
325/// by the config watcher (#45), not here.
326fn bootstrap_cmds(config: &Config) -> Vec<Cmd> {
327    // Instructions/memory load + stay fresh via the config watcher (#45),
328    // started in `run_interactive_with`. Bootstrap only handles MCP init.
329    let mut cmds = Vec::new();
330    if !config.mcp_servers.is_empty() {
331        cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
332    }
333    cmds
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn bootstrap_is_empty_without_mcp_servers() {
342        // Instructions/memory load via the config watcher (#45), not bootstrap;
343        // with no MCP servers configured, bootstrap emits nothing.
344        let cmds = bootstrap_cmds(&Config::default());
345        assert!(cmds.is_empty());
346    }
347
348    #[test]
349    fn bootstrap_skips_mcp_init_when_no_servers_configured() {
350        let cmds = bootstrap_cmds(&Config::default());
351        assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
352    }
353
354    #[test]
355    fn bootstrap_includes_mcp_init_when_servers_configured() {
356        let mut cfg = Config::default();
357        cfg.mcp_servers.insert(
358            "example".to_string(),
359            crate::app::McpServerConfig {
360                command: "echo".to_string(),
361                args: vec![],
362                env: std::collections::HashMap::new(),
363            },
364        );
365        let cmds = bootstrap_cmds(&cfg);
366        assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
367    }
368}