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}