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