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}