mermaid_cli/domain/msg.rs
1//! Every input to the reducer.
2//!
3//! `Msg` is an exhaustive sum over three categories:
4//!
5//! 1. **User intent** — key presses, pastes, slash commands, submit,
6//! cancel, quit. Originates from `app::event_source`.
7//! 2. **Effect results** — stream chunks, tool outcomes, MCP
8//! lifecycle, save/load completion. Originates from
9//! `effect::EffectRunner` when a spawned task finishes a unit of
10//! work.
11//! 3. **Housekeeping** — `Tick` (timer-driven redraw), `StatusDismiss`,
12//! `InstructionsChanged` (mtime watcher).
13//!
14//! Every effect-result variant carries a `TurnId`. The reducer's first
15//! gate on any such message is `if state.turn.accepts(msg.turn_id())`
16//! — messages for a cancelled / superseded turn are dropped without
17//! state change. This is the architectural guarantee that stale
18//! streaming events can never corrupt the current turn.
19
20use std::path::PathBuf;
21
22use crate::app::McpServerConfig;
23use crate::app::instructions::LoadedInstructions;
24use crate::models::tool_call::ToolCall as ModelToolCall;
25use crate::models::{ReasoningChunk, ReasoningLevel, TokenUsage, UserFacingError};
26use crate::runtime::{
27 ApprovalRecord, CheckpointRecord, MemoryEntry, PluginInstallRecord, ProcessRecord, TaskRecord,
28 TaskTimelineEvent,
29};
30
31use super::ids::{ToolCallId, TurnId};
32use super::runtime::RuntimeSignal;
33use super::state::ContextUsageSnapshot;
34use super::state::StatusKind;
35use super::state::{ConversationSummary, McpToolSpec, ToolOutcome};
36use super::{CompactionResult, CompactionTrigger};
37
38/// Single reducer input. Non-exhaustive is intentional: adding a new
39/// variant is a deliberate act that forces every reducer arm to
40/// consider it at compile time (the reducer's match is NOT
41/// `_ =>` — see `reducer.rs`).
42#[derive(Debug, Clone)]
43#[allow(clippy::large_enum_variant)]
44pub enum Msg {
45 // ── User intent ─────────────────────────────────────────────────
46 /// Raw key event from crossterm, after the event source has
47 /// stripped mouse/resize/paste.
48 Key(Key),
49 /// A full paste (text OR image) from the terminal.
50 Paste(Paste),
51 /// User hit Enter on a non-empty input. The event source has
52 /// already stripped the slash-command routing.
53 SubmitPrompt {
54 text: String,
55 /// Attachment IDs the reducer should consume from state.
56 attachment_ids: Vec<u64>,
57 },
58 /// User ran a slash command (post-routing from `app::event_source`).
59 Slash(SlashCmd),
60 /// Esc or another explicit cancellation source during an active turn.
61 CancelTurn,
62 /// Confirmation modal answer.
63 ConfirmAccepted,
64 ConfirmDeclined,
65 /// User wants to exit cleanly (Ctrl+D with empty input, or `/quit`).
66 Quit,
67 /// External process lifecycle signal. In raw-mode TUI sessions a
68 /// typed Ctrl+C still arrives as `Msg::Key`; this variant covers
69 /// OS-level SIGINT/SIGTERM/SIGHUP delivered from outside.
70 RuntimeSignal(RuntimeSignal),
71
72 // ── Streaming (from effect::model) ──────────────────────────────
73 /// Chunk of assistant text. Append to `partial_text`.
74 StreamText {
75 turn: TurnId,
76 chunk: String,
77 },
78 /// Chunk of reasoning / thinking content.
79 StreamReasoning {
80 turn: TurnId,
81 chunk: ReasoningChunk,
82 },
83 /// Model emitted a tool call. Append to the outgoing call list;
84 /// actual execution dispatches on `StreamDone`.
85 StreamToolCall {
86 turn: TurnId,
87 call: ModelToolCall,
88 },
89 /// Effect runner estimated the fully-enriched request context
90 /// after built-in and MCP tool schemas were attached.
91 ContextUsageEstimated {
92 turn: TurnId,
93 snapshot: ContextUsageSnapshot,
94 },
95 /// Context compaction completed and produced a replacement
96 /// model-visible history.
97 CompactionFinished {
98 turn: TurnId,
99 result: CompactionResult,
100 },
101 /// Context compaction failed or no-oped. Manual failures end the
102 /// compaction turn; auto failures may leave generation running.
103 CompactionFailed {
104 turn: TurnId,
105 trigger: CompactionTrigger,
106 message: String,
107 kind: StatusKind,
108 },
109 /// Stream complete. Carries final token count (0 if unknown) and,
110 /// for Anthropic, the thinking signature that must round-trip on
111 /// the next request.
112 StreamDone {
113 turn: TurnId,
114 usage: Option<TokenUsage>,
115 thinking_signature: Option<String>,
116 },
117 /// Upstream returned a recoverable or terminal error. Reducer
118 /// commits an error line and returns to `Idle` (or surfaces a
119 /// retry affordance, if `recoverable`).
120 UpstreamError {
121 turn: TurnId,
122 error: UserFacingError,
123 },
124 /// Terminal event for a cancelled turn. Emitted by the effect
125 /// runner's `drop_scope` once every child task in the turn's
126 /// `TurnScope` has unwound. Reducer transitions
127 /// `Cancelling(id) → Idle` when it arrives.
128 ///
129 /// Without this, the reducer relies on the (wrong) side-channel of
130 /// `UpstreamError` arriving from a cancelled provider call to exit
131 /// `Cancelling`. If the provider task is aborted before it can
132 /// emit an error, the state would stick in `Cancelling` forever.
133 TurnCancelled(TurnId),
134
135 // ── Tools (from effect::tool) ───────────────────────────────────
136 /// Tool was picked up by the executor — useful for "spinner
137 /// started" UI transitions.
138 ToolStarted {
139 turn: TurnId,
140 call_id: ToolCallId,
141 },
142 /// Mid-flight progress (streaming subprocess output, byte-count
143 /// updates, multimodal artifacts, nested subagent activity).
144 /// Reducer pattern-matches the variant and routes accordingly:
145 /// text variants update the status line; `Artifact` with an
146 /// `image/*` mime attaches to the in-flight assistant message;
147 /// `Subagent*` variants render as indented status.
148 ToolProgress {
149 turn: TurnId,
150 call_id: ToolCallId,
151 event: crate::providers::ProgressEvent,
152 },
153 /// Tool finished (one of Finished / Error / Cancelled).
154 ToolFinished {
155 turn: TurnId,
156 call_id: ToolCallId,
157 outcome: ToolOutcome,
158 },
159
160 // ── MCP (from effect::mcp) ──────────────────────────────────────
161 /// `initialize` succeeded; server is ready to dispatch tools.
162 McpServerReady {
163 name: String,
164 tools: Vec<McpToolSpec>,
165 },
166 /// Server startup failed OR the child exited with non-zero.
167 McpServerErrored {
168 name: String,
169 reason: String,
170 },
171 McpServerStopped {
172 name: String,
173 },
174
175 // ── Persistence (from effect::persistence) ──────────────────────
176 /// `MERMAID.md` loaded / changed / removed since last check.
177 InstructionsChanged(Option<LoadedInstructions>),
178 /// `save_conversation` finished.
179 SessionSaved,
180 /// `/load <id>` — a saved conversation has been read off disk.
181 ConversationLoaded(crate::session::ConversationHistory),
182 /// Response to `Cmd::ListConversations`. Populates the `/load`
183 /// picker's candidate list.
184 ConversationsListed(Vec<ConversationSummary>),
185 /// Response to `/tasks`.
186 RuntimeTasksListed(Vec<TaskRecord>),
187 /// Response to `/task <id>`.
188 RuntimeTaskLoaded {
189 task: Option<TaskRecord>,
190 events: Vec<TaskTimelineEvent>,
191 },
192 /// Response to `/processes`.
193 RuntimeProcessesListed(Vec<ProcessRecord>),
194 /// Generic daemon/runtime text response.
195 RuntimeText(String),
196 RuntimeApprovalsListed(Vec<ApprovalRecord>),
197 RuntimeCheckpointsListed(Vec<CheckpointRecord>),
198 RuntimeMemoryListed(Vec<MemoryEntry>),
199 RuntimePluginsListed(Vec<PluginInstallRecord>),
200
201 // ── Misc model operations ───────────────────────────────────────
202 /// `/model <name>` finished pulling (Ollama only).
203 ModelPullFinished {
204 model: String,
205 },
206 /// Streaming stdout line from an `ollama pull` subprocess.
207 /// Reducer forwards to the status line for the user to watch.
208 ModelPullProgress(String),
209
210 // ── Housekeeping ────────────────────────────────────────────────
211 /// 1/60s timer tick. Used for spinner animation + elapsed-time
212 /// display. Reducer only advances derived fields.
213 Tick,
214 /// Status line expired (self-clear) or user dismissed.
215 StatusDismiss,
216 /// Terminal was resized. Reducer normally no-ops; render consumes.
217 Resize {
218 width: u16,
219 height: u16,
220 },
221
222 // ── Status feedback from async effects ─────────────────────────
223 /// Set `state.status` to `(text, kind)` and schedule automatic
224 /// dismissal after `dismiss_ms`. Used by effect handlers that
225 /// need to surface user-visible feedback without a bespoke Msg
226 /// per effect — today that's clipboard-read success / failure
227 /// (F14), but the variant is general and other effects will reuse
228 /// it. Reducer handles this arm by setting `state.status` and
229 /// pushing `Cmd::DismissStatusAfter { ms: dismiss_ms }`.
230 TransientStatus {
231 text: String,
232 kind: super::state::StatusKind,
233 dismiss_ms: u64,
234 },
235
236 // ── Mouse (F13) ─────────────────────────────────────────────────
237 /// Mouse-wheel scroll in the chat pane. Positive delta = scroll
238 /// toward older messages (up), negative = toward newer (down). The
239 /// reducer tracks the scroll offset on `ui.chat_scroll`; the
240 /// ChatWidget reads it during render.
241 MouseScroll {
242 delta: i16,
243 },
244 /// Ctrl+Click on an image thumbnail in the chat pane. The
245 /// coordinates are absolute screen row/col; the render cache's
246 /// `ChatState::find_image_at_screen_pos` maps them to a
247 /// `(message_index, image_index)` pair. The main loop handles the
248 /// lookup before forwarding this message to the reducer, so by
249 /// the time the reducer sees it, the target has already been
250 /// resolved into a base64 payload and this Msg carries the
251 /// already-decoded image. The reducer just emits
252 /// `Cmd::WriteImageToTemp` + `Cmd::OpenInSystem`.
253 OpenImageAt {
254 message_index: usize,
255 image_index: usize,
256 },
257}
258
259/// Bare key event — deliberately smaller than crossterm's `KeyEvent`
260/// so the reducer doesn't depend on crossterm. The app event source
261/// does the conversion.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub struct Key {
264 pub code: KeyCode,
265 pub modifiers: KeyMods,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum KeyCode {
270 Char(char),
271 Enter,
272 Escape,
273 Backspace,
274 Delete,
275 Tab,
276 BackTab,
277 Left,
278 Right,
279 Up,
280 Down,
281 Home,
282 End,
283 PageUp,
284 PageDown,
285 F(u8),
286 /// Anything we don't care about (media keys, etc.).
287 Unknown,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
291pub struct KeyMods {
292 pub ctrl: bool,
293 pub alt: bool,
294 pub shift: bool,
295}
296
297impl KeyMods {
298 pub const NONE: Self = Self {
299 ctrl: false,
300 alt: false,
301 shift: false,
302 };
303
304 pub const fn ctrl() -> Self {
305 Self {
306 ctrl: true,
307 ..Self::NONE
308 }
309 }
310
311 pub const fn alt() -> Self {
312 Self {
313 alt: true,
314 ..Self::NONE
315 }
316 }
317
318 pub fn is_empty(self) -> bool {
319 !self.ctrl && !self.alt && !self.shift
320 }
321}
322
323/// Paste payload. Images come in as raw bytes; text as UTF-8.
324#[derive(Debug, Clone)]
325pub enum Paste {
326 Text(String),
327 Image { bytes: Vec<u8>, format: String },
328}
329
330/// Slash commands — a typed surface over what the user typed as
331/// `/<name> [args]`. Parsed in `app::event_source` against the single
332/// `COMMAND_REGISTRY`; unknown commands produce `SlashCmd::Unknown`
333/// so the reducer can issue a "no such command" status line.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum SlashCmd {
336 /// No arg → show current; `Some` → switch (and pull if needed).
337 Model(Option<String>),
338 Reasoning(Option<ReasoningLevel>),
339 VisibleReasoning(Option<String>),
340 Clear,
341 Save(Option<String>),
342 Load(Option<String>),
343 List,
344 Usage,
345 Context,
346 Compact(Option<String>),
347 Doctor,
348 Tasks,
349 Task(Option<String>),
350 Pause(Option<String>),
351 Resume(Option<String>),
352 Cancel(Option<String>),
353 Handoff(Option<String>),
354 Report(Option<String>),
355 Processes,
356 Logs(Option<String>),
357 Stop(Option<String>),
358 Restart(Option<String>),
359 Open(Option<String>),
360 Ports,
361 Approvals,
362 Approve(Option<String>),
363 Deny(Option<String>),
364 Checkpoint(Option<String>),
365 Checkpoints,
366 Restore(Option<String>),
367 Memory,
368 MemoryEdit(Option<String>),
369 Remember(Option<String>),
370 Forget(Option<String>),
371 ModelInfo(Option<String>),
372 Plugins,
373 CloudSetup,
374 Help,
375 Quit,
376 /// User typed something that isn't in the registry; carries the
377 /// raw name for the error message.
378 Unknown(String),
379}
380
381impl Msg {
382 /// Extract the `TurnId` for effect-result variants. Returns `None`
383 /// for variants that aren't turn-scoped (user intent,
384 /// housekeeping, MCP lifecycle). The reducer uses this to
385 /// short-circuit stale events.
386 pub fn turn_id(&self) -> Option<TurnId> {
387 match self {
388 Msg::StreamText { turn, .. }
389 | Msg::StreamReasoning { turn, .. }
390 | Msg::StreamToolCall { turn, .. }
391 | Msg::ContextUsageEstimated { turn, .. }
392 | Msg::CompactionFinished { turn, .. }
393 | Msg::CompactionFailed { turn, .. }
394 | Msg::StreamDone { turn, .. }
395 | Msg::UpstreamError { turn, .. }
396 | Msg::ToolStarted { turn, .. }
397 | Msg::ToolProgress { turn, .. }
398 | Msg::ToolFinished { turn, .. } => Some(*turn),
399 Msg::TurnCancelled(turn) => Some(*turn),
400 _ => None,
401 }
402 }
403
404 /// Classification for telemetry / replay tooling. Cheaper than a
405 /// full `Debug` string and stable across refactors.
406 pub fn kind(&self) -> MsgKind {
407 match self {
408 Msg::Key(_) => MsgKind::Key,
409 Msg::Paste(_) => MsgKind::Paste,
410 Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
411 Msg::Slash(_) => MsgKind::Slash,
412 Msg::CancelTurn => MsgKind::CancelTurn,
413 Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
414 Msg::Quit => MsgKind::Quit,
415 Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
416 Msg::StreamText { .. } => MsgKind::StreamText,
417 Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
418 Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
419 Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
420 Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
421 Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
422 Msg::StreamDone { .. } => MsgKind::StreamDone,
423 Msg::UpstreamError { .. } => MsgKind::UpstreamError,
424 Msg::ToolStarted { .. } => MsgKind::ToolStarted,
425 Msg::ToolProgress { .. } => MsgKind::ToolProgress,
426 Msg::ToolFinished { .. } => MsgKind::ToolFinished,
427 Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
428 Msg::McpServerReady { .. }
429 | Msg::McpServerErrored { .. }
430 | Msg::McpServerStopped { .. } => MsgKind::Mcp,
431 Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
432 Msg::SessionSaved => MsgKind::SessionSaved,
433 Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
434 Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
435 Msg::RuntimeTasksListed(_)
436 | Msg::RuntimeTaskLoaded { .. }
437 | Msg::RuntimeProcessesListed(_)
438 | Msg::RuntimeText(_)
439 | Msg::RuntimeApprovalsListed(_)
440 | Msg::RuntimeCheckpointsListed(_)
441 | Msg::RuntimeMemoryListed(_)
442 | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
443 Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
444 Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
445 Msg::Tick => MsgKind::Tick,
446 Msg::StatusDismiss => MsgKind::StatusDismiss,
447 Msg::Resize { .. } => MsgKind::Resize,
448 Msg::MouseScroll { .. } => MsgKind::MouseScroll,
449 Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
450 Msg::TransientStatus { .. } => MsgKind::TransientStatus,
451 }
452 }
453}
454
455/// Compact kind tag for tracing / replay indexing.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum MsgKind {
458 Key,
459 Paste,
460 SubmitPrompt,
461 Slash,
462 CancelTurn,
463 Confirm,
464 Quit,
465 RuntimeSignal,
466 StreamText,
467 StreamReasoning,
468 StreamToolCall,
469 ContextUsageEstimated,
470 CompactionFinished,
471 CompactionFailed,
472 StreamDone,
473 UpstreamError,
474 ToolStarted,
475 ToolProgress,
476 ToolFinished,
477 TurnCancelled,
478 Mcp,
479 InstructionsChanged,
480 SessionSaved,
481 ConversationLoaded,
482 ConversationsListed,
483 RuntimeStore,
484 ModelPullFinished,
485 ModelPullProgress,
486 Tick,
487 StatusDismiss,
488 Resize,
489 MouseScroll,
490 OpenImageAt,
491 TransientStatus,
492}
493
494/// Helper for `app::event_source` — pass through the MCP config that
495/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
496/// Not a `Msg` because it's startup-only.
497#[derive(Debug, Clone)]
498pub struct StartupConfig {
499 pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
500 pub cwd: PathBuf,
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn turn_id_extracted_from_stream_messages() {
509 let m = Msg::StreamText {
510 turn: TurnId(7),
511 chunk: "hi".to_string(),
512 };
513 assert_eq!(m.turn_id(), Some(TurnId(7)));
514 }
515
516 #[test]
517 fn turn_id_none_for_user_intent() {
518 let m = Msg::CancelTurn;
519 assert_eq!(m.turn_id(), None);
520 let m = Msg::Quit;
521 assert_eq!(m.turn_id(), None);
522 let m = Msg::Tick;
523 assert_eq!(m.turn_id(), None);
524 }
525
526 #[test]
527 fn turn_id_none_for_mcp_lifecycle() {
528 let m = Msg::McpServerReady {
529 name: "s".to_string(),
530 tools: vec![],
531 };
532 assert_eq!(m.turn_id(), None);
533 }
534
535 #[test]
536 fn key_mods_builder_defaults_match_const() {
537 assert_eq!(KeyMods::default(), KeyMods::NONE);
538 assert!(KeyMods::ctrl().ctrl);
539 assert!(!KeyMods::ctrl().alt);
540 assert!(!KeyMods::ctrl().shift);
541 }
542
543 #[test]
544 fn kind_stable_across_variants() {
545 assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
546 assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
547 assert_eq!(
548 Msg::StreamText {
549 turn: TurnId(1),
550 chunk: String::new()
551 }
552 .kind(),
553 MsgKind::StreamText
554 );
555 }
556
557 #[test]
558 fn slash_cmd_carries_none_for_no_arg() {
559 let c = SlashCmd::Model(None);
560 assert_eq!(c, SlashCmd::Model(None));
561 assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
562 }
563}