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}