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