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