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