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 ratatui::layout::Rect;
26use tokio::time::{Duration, interval};
27
28use crate::app::Config;
29use crate::app::event_source::coalesce_key_burst;
30use crate::app::lifecycle::RuntimeLifecycle;
31use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder, SessionHeader};
32use crate::app::terminal::TerminalGuard;
33use crate::domain::{Cmd, Msg, RuntimeSignal, State, update};
34use crate::effect::EffectRunner;
35use crate::providers::ToolRegistry;
36use crate::render::{RenderCache, render};
37use crate::session::ConversationHistory;
38
39/// Options for `run_interactive_with`. Added so new flags land without
40/// reshuffling positional args.
41///
42/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
43/// Debug. The bigger picture is that nothing prints these — they're an
44/// argument bundle, not telemetry.
45#[derive(Default)]
46pub struct InteractiveOptions {
47 /// Optional recorder for `--record <file>` JSONL capture.
48 pub recorder: Option<Recorder>,
49 /// Optional conversation to seed the session with (e.g. from
50 /// `--continue` or `--sessions`). When `Some`, the seeded history
51 /// replaces `State::session.conversation` before the first frame.
52 pub seed_conversation: Option<ConversationHistory>,
53}
54
55/// Interactive TUI main loop with explicit options. `recorder` (if
56/// provided) appends one JSONL line per reducer input to the file for
57/// debugging / replay.
58pub async fn run_interactive_with(
59 mut config: Config,
60 cwd: PathBuf,
61 model_id: String,
62 mut opts: InteractiveOptions,
63) -> Result<()> {
64 // One startup clock read, shared by `State::new` and the recording
65 // header: replay seeds `State::new` with the recorded value and gets the
66 // same initial conversation id/title.
67 let startup_now = chrono::Local::now();
68 // Fold enabled plugins' MCP servers + agent types into the merged config
69 // BEFORE anything consumes it (State::new seeds server rows, the
70 // recording header captures the merged config — replay-faithful, and the
71 // provider factory + tool registry see the same view).
72 let plugin_assets = crate::app::plugin_assets::load();
73 let plugin_warnings = crate::app::plugin_assets::apply(&mut config, &plugin_assets);
74 let mut state = State::new(config.clone(), cwd.clone(), model_id.clone(), startup_now);
75 let seed = opts.seed_conversation.take();
76 if let Some(r) = opts.recorder.as_mut() {
77 // The header makes a recording self-contained: `--replay` rebuilds
78 // the initial State from it (config, model, cwd, seed) without
79 // reading this machine's live config. Written before the first Msg
80 // so even a crashed session leaves a parseable log.
81 r.record_header(&SessionHeader {
82 format: RECORDING_FORMAT_VERSION,
83 ts: startup_now,
84 model_id: model_id.clone(),
85 cwd: cwd.clone(),
86 config: config.clone(),
87 seed_conversation: seed.clone(),
88 })?;
89 }
90 if let Some(history) = seed {
91 // `--continue` / `--resume` seed — shared with `--replay` via
92 // `State::seed_conversation` so both build the same starting state.
93 state.seed_conversation(history);
94 }
95 crate::app::stamp_session_provenance(&mut state, &cwd);
96 // NO_COLOR (https://no-color.org): present and non-empty disables all
97 // color. Read once here — the reducer never touches the environment; the
98 // render layer resolves `Theme::plain()` off this flag.
99 state.ui.no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
100 // Skills load once at startup (authored artifacts, no watcher); the config
101 // watcher below keeps only instructions/memory fresh.
102 state.skills = crate::app::skills::load(&cwd);
103 // Plugin prompt commands: same restart-to-refresh policy as skills.
104 state.plugin_commands = plugin_assets.commands;
105 for warning in plugin_warnings {
106 state
107 .ui
108 .pending_msgs
109 .push_back(Msg::TransientStatus { text: warning });
110 }
111 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
112 let tools = ToolRegistry::build(
113 &config,
114 crate::providers::TuiMode::Interactive,
115 providers.clone(),
116 );
117 let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
118 // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
119 // escalations) pause and prompt instead of erroring out, and inline
120 // `ask_user_question` prompts so the model can ask the user structured
121 // questions mid-run instead of proceeding without them.
122 let mut runner = runner
123 .with_interactive_approvals()
124 .with_interactive_questions();
125 // Keep instructions/memory fresh via the background config watcher (#45):
126 // it emits Msg::InstructionsChanged/MemoryChanged on change, so the reducer
127 // reads them as injected data and never does the refresh I/O inline.
128 runner.spawn_config_watcher(cwd.clone(), config.memory.clone());
129 let mut terminal = Some(TerminalGuard::setup()?);
130 let mut rstate = RenderCache::new();
131 // `Option` because the $EDITOR compose round-trip must DROP the stream
132 // (its reader thread holds crossterm's internal reader mutex) before
133 // suspending, and build a fresh one after — same lifecycle dance as
134 // `terminal` above.
135 let mut events = Some(EventStream::new());
136 let mut lifecycle = RuntimeLifecycle::new();
137 let mut tick = interval(Duration::from_millis(16));
138 let mut recorder = opts.recorder;
139
140 // Boot effects: MCP server init (if configured). Instructions/memory are
141 // loaded by the config watcher started above (#45), not here.
142 for cmd in bootstrap_cmds(&config, &state.session.conversation.id) {
143 runner.dispatch(cmd);
144 }
145 // A resumed session may carry an in-flight checklist; hand it to the
146 // TaskBroker (tool-side truth) so the first task tool call of the new
147 // process starts from the restored list instead of an empty one.
148 if !state.session.conversation.tasks.tasks.is_empty() {
149 runner.dispatch(crate::domain::Cmd::SyncTaskStore(
150 state.session.conversation.tasks.clone(),
151 ));
152 }
153
154 // Which `select!` arm fired. Terminal events are handled *after* the
155 // select! returns so the paste-coalescing drain can borrow `events`
156 // again without tripping the borrow checker.
157 //
158 // `Msg` is the large variant, but this enum lives on the stack for one
159 // loop iteration and `Msg` is passed by value everywhere already —
160 // boxing it would add a per-event heap alloc on the hot input path.
161 #[allow(clippy::large_enum_variant)]
162 enum Sel {
163 Msg(Option<Msg>),
164 Term(Option<Result<crossterm::event::Event, std::io::Error>>),
165 }
166
167 // Msgs produced ahead of time — e.g. a non-paste event drained while
168 // coalescing a key burst. Processed before pulling the next event.
169 let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
170
171 // Main loop. A fatal error inside the loop is captured here and returned
172 // AFTER the orderly-shutdown path below, so a draw failure can't skip MCP
173 // child cleanup / pending-save drains (the terminal is still restored by
174 // `TerminalGuard::Drop` regardless).
175 let mut exit_result: Result<()> = Ok(());
176 // Last-seen `full_redraw_seq`. When the reducer bumps it (shell command
177 // finished, Ctrl+L), `Terminal::clear()` resets ratatui's back buffer so
178 // the next draw repaints every cell — the only way to overwrite bytes
179 // some other process wrote directly to the tty (ghost cells).
180 let mut seen_redraw_seq = state.ui.full_redraw_seq;
181 loop {
182 // Render the current state. ratatui's draw closure captures
183 // &state, so we don't thread &mut state through the renderer.
184 {
185 let term = terminal
186 .as_mut()
187 .expect("terminal guard is alive while the render loop runs")
188 .inner_mut();
189 if state.ui.full_redraw_seq != seen_redraw_seq {
190 seen_redraw_seq = state.ui.full_redraw_seq;
191 // NOT `Terminal::clear()`: it snapshots the cursor with an
192 // ESC[6n round-trip, and the reply never arrives — the
193 // `EventStream` reader thread is parked holding crossterm's
194 // internal reader mutex and swallows it — so the query dies
195 // fatally after crossterm's 2s deadline. `resize()` to the
196 // current size performs the same full clear + back-buffer
197 // reset for a Fullscreen viewport without querying the tty.
198 let repaint = term
199 .size()
200 .and_then(|size| term.resize(Rect::new(0, 0, size.width, size.height)));
201 if let Err(err) = repaint {
202 exit_result = Err(err.into());
203 break;
204 }
205 }
206 if let Err(err) = term.draw(|f| render(&state, &mut rstate, f)) {
207 exit_result = Err(err.into());
208 break;
209 }
210 }
211
212 // Drain any msgs queued by a prior burst-coalesce before blocking
213 // on the next event.
214 let msg = if let Some(queued) = pending_msgs.pop_front() {
215 Some(queued)
216 } else {
217 let selected = tokio::select! {
218 // Fair (unbiased) polling. With `biased;`, the hot `msg_rx`
219 // arm would always win under sustained streaming and starve
220 // terminal input + OS signals (#112). Fair selection still
221 // drains streaming promptly — it's almost always ready — while
222 // guaranteeing the input/signal/tick arms get serviced too.
223 //
224 // Effect results (streaming chunks, tool output, …).
225 m = msg_rx.recv() => Sel::Msg(m),
226 // Crossterm events. Handled below, outside the select!, so
227 // coalescing can re-borrow `events`.
228 e = events.as_mut().expect("event stream is alive while the loop runs").next() => Sel::Term(e),
229 // OS lifecycle signals. A typed Ctrl+C in raw mode is handled
230 // by the crossterm branch above; this covers SIGINT/SIGTERM/
231 // SIGHUP delivered externally.
232 s = lifecycle.next_msg() => Sel::Msg(s),
233 // Tick — drives elapsed-time displays + self-dismissing status
234 // lines without busy-waiting.
235 _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
236 };
237
238 match selected {
239 Sel::Msg(m) => m,
240 Sel::Term(Some(Ok(evt))) => {
241 if let crossterm::event::Event::Mouse(m) = &evt {
242 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
243 let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
244 match m.kind {
245 // F13: Ctrl+Click a chat image tile opens it via
246 // the system viewer. The screen→image mapping
247 // lives in ChatState (the render layer).
248 MEK::Down(MouseButton::Left) if ctrl => rstate
249 .chat
250 .find_image_at_screen_pos(m.row)
251 .map(|target| Msg::OpenImageAt {
252 message_index: target.message_index,
253 image_index: target.image_index,
254 image_number: target.image_number,
255 }),
256 // Plain (no-modifier) left drag selects chat text.
257 // Handled render-side so wheel-scroll + Ctrl+Click
258 // keep working; on release we copy the selection.
259 MEK::Down(MouseButton::Left) => {
260 rstate.chat.begin_selection(m.row, m.column);
261 None
262 },
263 MEK::Drag(MouseButton::Left) => {
264 rstate.chat.update_selection(m.row, m.column);
265 None
266 },
267 MEK::Up(MouseButton::Left) => {
268 // A drag only *selects* (the highlight persists);
269 // copying is an explicit action (Ctrl+Shift+C).
270 // Auto-copying on release would silently clobber
271 // the user's clipboard.
272 None
273 },
274 MEK::ScrollUp => Some(Msg::MouseScroll {
275 delta: crate::constants::UI_MOUSE_SCROLL_LINES as i16,
276 }),
277 MEK::ScrollDown => Some(Msg::MouseScroll {
278 delta: -(crate::constants::UI_MOUSE_SCROLL_LINES as i16),
279 }),
280 _ => None,
281 }
282 } else {
283 // Non-mouse event. Ctrl+Shift+C copies the current chat
284 // selection — the explicit copy step after a drag-select.
285 // Because the app holds the mouse, the terminal has no
286 // selection of its own and passes the shortcut through.
287 // The SHIFT bit only arrives when the kitty keyboard
288 // protocol was negotiated at setup (TerminalGuard); on
289 // legacy terminals Ctrl+Shift+C is transmitted as the
290 // identical byte 0x03 as Ctrl+C — physically
291 // indistinguishable — so there it falls through to the
292 // reducer's Ctrl+C handling (press-twice-to-exit keeps
293 // a stray copy-chord harmless).
294 if let crossterm::event::Event::Key(k) = &evt
295 && k.kind == crossterm::event::KeyEventKind::Press
296 && k.modifiers
297 .contains(crossterm::event::KeyModifiers::CONTROL)
298 && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
299 && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
300 {
301 // Route the copy through the reducer (#18): the
302 // selection lives in the render layer, but emitting a
303 // Msg keeps the clipboard side effect recorded +
304 // replayable instead of dispatched out-of-band.
305 rstate
306 .chat
307 .selected_text()
308 .filter(|t| !t.is_empty())
309 .map(Msg::CopySelection)
310 } else {
311 // Coalesce a paste burst (crossterm 0.29 doesn't
312 // deliver Event::Paste on the Windows console — a
313 // paste arrives as a flood of Char/Enter key events).
314 // The drain pulls every immediately-available event
315 // so the whole block lands as one atomic Msg::Paste.
316 let (primary, trailing) = coalesce_key_burst(evt, || {
317 events
318 .as_mut()
319 .expect("event stream is alive while the loop runs")
320 .next()
321 .now_or_never()
322 .flatten()
323 .and_then(|r| r.ok())
324 });
325 for queued in trailing {
326 pending_msgs.push_back(queued);
327 }
328 primary
329 }
330 }
331 },
332 Sel::Term(Some(Err(error))) => {
333 tracing::warn!(error = %error, "terminal event stream failed");
334 None
335 },
336 Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
337 }
338 };
339
340 let Some(msg) = msg else { continue };
341
342 // Inject the wall clock as data (Cause 3): one stamp per tick, shared
343 // by the recording and the reducer. The recorded `ts` IS the
344 // `state.now` this Msg was reduced under, so `--replay` folds the
345 // same log by stamping each entry's `ts` here and recomputes the
346 // exact same states.
347 let now = chrono::Local::now();
348
349 // Optional recording: one JSONL line per Msg, before the
350 // reducer runs so the log captures even no-op inputs.
351 if let Some(r) = recorder.as_mut()
352 && let Err(err) = r.record_msg(now, &msg)
353 {
354 tracing::warn!(error = %err, "recorder: failed to record message; --replay may be non-deterministic");
355 }
356
357 state.now = now;
358 let (new_state, cmds) = update(state, msg);
359 state = new_state;
360 // `ComposeInEditor` is run-loop-owned (it suspends the terminal +
361 // event stream, which only this loop holds); everything else goes to
362 // the effect runner. At most one compose per reducer step by
363 // construction (single Ctrl+O / /editor arm).
364 let mut compose_draft: Option<String> = None;
365 for cmd in cmds {
366 if let Cmd::ComposeInEditor { text } = cmd {
367 compose_draft = Some(text);
368 } else {
369 runner.dispatch(cmd);
370 }
371 }
372 if let Some(draft) = compose_draft {
373 match crate::app::editor::compose_in_editor(&mut terminal, &mut events, draft).await {
374 // Through pending_msgs, so the result flows through the
375 // recorder like any input — --replay never launches an editor.
376 Ok(msg) => pending_msgs.push_back(msg),
377 Err(err) => {
378 exit_result = Err(err);
379 break;
380 },
381 }
382 }
383
384 if state.should_exit {
385 break;
386 }
387 }
388
389 // Seal the recording with a fingerprint of the final session, so a
390 // future `--replay` can verify its fold reproduces what this live
391 // session actually saw — not merely that the fold is self-consistent.
392 // (Wall-clock read is fine here: we're outside the reducer.)
393 if let Some(r) = recorder.as_mut()
394 && let Err(err) = r.record_trailer(chrono::Local::now(), &state.session)
395 {
396 tracing::warn!(error = %err, "recorder: failed to write replay trailer");
397 }
398
399 // Restore the user's terminal before async shutdown. Shutdown can
400 // wait on pending saves / cancelled scopes for a bounded period;
401 // keeping raw mode + mouse capture alive during that wait makes
402 // Ctrl+C feel ignored and can leak mouse escape sequences into
403 // the shell if the user keeps interacting.
404 drop(events);
405 if let Some(mut terminal) = terminal.take() {
406 terminal.restore_now();
407 }
408
409 // Orderly shutdown — wait for any pending saves / scope cleanup. Runs even
410 // when the loop broke on a draw error, so MCP children are reaped cleanly.
411 runner.shutdown().await;
412 exit_result
413}
414
415/// Commands dispatched on startup before the first iteration of the
416/// loop. Fires MCP init (if configured) and materializes the session's
417/// scratch directory. Instructions/memory are loaded by the config
418/// watcher (#45), not here.
419fn bootstrap_cmds(config: &Config, session_id: &str) -> Vec<Cmd> {
420 // Instructions/memory load + stay fresh via the config watcher (#45),
421 // started in `run_interactive_with`.
422 let mut cmds = Vec::new();
423 if !config.mcp_servers.is_empty() {
424 cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
425 }
426 // Every session gets a scratch dir — `session_id` is captured AFTER any
427 // `--continue`/`--resume` seed, so a resumed session adopts the dir
428 // keyed by its restored conversation id.
429 cmds.push(Cmd::EnsureScratchpad {
430 session_id: session_id.to_string(),
431 });
432 cmds
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn bootstrap_always_ensures_the_session_scratchpad() {
441 // Instructions/memory load via the config watcher (#45), not
442 // bootstrap; with no MCP servers configured, only the scratchpad
443 // ensure remains — keyed by the caller's session id.
444 let cmds = bootstrap_cmds(&Config::default(), "sess-1");
445 assert_eq!(cmds.len(), 1);
446 assert!(
447 cmds.iter().any(
448 |c| matches!(c, Cmd::EnsureScratchpad { session_id } if session_id == "sess-1")
449 )
450 );
451 }
452
453 #[test]
454 fn bootstrap_skips_mcp_init_when_no_servers_configured() {
455 let cmds = bootstrap_cmds(&Config::default(), "sess-1");
456 assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
457 }
458
459 #[test]
460 fn bootstrap_includes_mcp_init_when_servers_configured() {
461 let mut cfg = Config::default();
462 cfg.mcp_servers.insert(
463 "example".to_string(),
464 crate::app::McpServerConfig {
465 command: "echo".to_string(),
466 args: vec![],
467 env: std::collections::HashMap::new(),
468 ..Default::default()
469 },
470 );
471 let cmds = bootstrap_cmds(&cfg, "sess-1");
472 assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
473 }
474}