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, PluginInstallRecord, ProcessRecord, SafetyMode, 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::{ApprovalKind, 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 /// A gated tool is awaiting the user's inline approval (interactive
160 /// `ask` mode / Auto-mode escalation). The reducer enqueues a modal; the
161 /// answer flows back as `Cmd::ResolveApproval`. The tool task is parked
162 /// until then, so the turn naturally pauses (its outcome slot stays
163 /// `None`).
164 ApprovalRequested {
165 turn: TurnId,
166 call_id: ToolCallId,
167 tool: String,
168 risk: String,
169 kind: ApprovalKind,
170 prompt: String,
171 allowlist_scope: String,
172 },
173
174 // ── MCP (from effect::mcp) ──────────────────────────────────────
175 /// `initialize` succeeded; server is ready to dispatch tools.
176 McpServerReady {
177 name: String,
178 tools: Vec<McpToolSpec>,
179 },
180 /// Server startup failed OR the child exited with non-zero.
181 McpServerErrored {
182 name: String,
183 reason: String,
184 },
185 McpServerStopped {
186 name: String,
187 },
188
189 // ── Persistence (from effect::persistence) ──────────────────────
190 /// `MERMAID.md` loaded / changed / removed since last check.
191 InstructionsChanged(Option<LoadedInstructions>),
192 /// Memory files loaded / changed / removed since last check.
193 MemoryChanged(Option<crate::app::memory::LoadedMemory>),
194 /// `save_conversation` finished.
195 SessionSaved,
196 /// `/load <id>` — a saved conversation has been read off disk.
197 ConversationLoaded(crate::session::ConversationHistory),
198 /// Response to `Cmd::ListConversations`. Populates the `/load`
199 /// picker's candidate list.
200 ConversationsListed(Vec<ConversationSummary>),
201 /// Response to `/tasks`.
202 RuntimeTasksListed(Vec<TaskRecord>),
203 /// Response to `/task <id>`.
204 RuntimeTaskLoaded {
205 task: Option<TaskRecord>,
206 events: Vec<TaskTimelineEvent>,
207 },
208 /// Response to `/processes`.
209 RuntimeProcessesListed(Vec<ProcessRecord>),
210 /// Generic daemon/runtime text response.
211 RuntimeText(String),
212 RuntimeApprovalsListed(Vec<ApprovalRecord>),
213 RuntimeCheckpointsListed(Vec<CheckpointRecord>),
214 RuntimePluginsListed(Vec<PluginInstallRecord>),
215
216 // ── Misc model operations ───────────────────────────────────────
217 /// `/model <name>` finished pulling (Ollama only).
218 ModelPullFinished {
219 model: String,
220 },
221 /// Streaming stdout line from an `ollama pull` subprocess.
222 /// Reducer forwards to the status line for the user to watch.
223 ModelPullProgress(String),
224
225 // ── Housekeeping ────────────────────────────────────────────────
226 /// 1/60s timer tick. Used for spinner animation + elapsed-time
227 /// display. Reducer only advances derived fields.
228 Tick,
229 /// Status line expired (self-clear) or user dismissed.
230 StatusDismiss,
231 /// Terminal was resized. Reducer normally no-ops; render consumes.
232 Resize {
233 width: u16,
234 height: u16,
235 },
236
237 // ── Status feedback from async effects ─────────────────────────
238 /// Set `state.status` to `(text, kind)` and schedule automatic
239 /// dismissal after `dismiss_ms`. Used by effect handlers that
240 /// need to surface user-visible feedback without a bespoke Msg
241 /// per effect — today that's clipboard-read success / failure
242 /// (F14), but the variant is general and other effects will reuse
243 /// it. Reducer handles this arm by setting `state.status` and
244 /// pushing `Cmd::DismissStatusAfter { ms: dismiss_ms }`.
245 TransientStatus {
246 text: String,
247 kind: super::state::StatusKind,
248 dismiss_ms: u64,
249 },
250
251 // ── Mouse (F13) ─────────────────────────────────────────────────
252 /// Mouse-wheel scroll in the chat pane. Positive delta = scroll
253 /// toward older messages (up), negative = toward newer (down). The
254 /// reducer tracks the scroll offset on `ui.chat_scroll`; the
255 /// ChatWidget reads it during render.
256 MouseScroll {
257 delta: i16,
258 },
259 /// Ctrl+Click on an image thumbnail in the chat pane. The
260 /// coordinates are absolute screen row/col; the render cache's
261 /// `ChatState::find_image_at_screen_pos` maps them to a
262 /// `(message_index, image_index)` pair. The main loop handles the
263 /// lookup before forwarding this message to the reducer, so by
264 /// the time the reducer sees it, the target has already been
265 /// resolved into a base64 payload and this Msg carries the
266 /// already-decoded image. The reducer just emits
267 /// `Cmd::WriteImageToTemp` + `Cmd::OpenInSystem`.
268 OpenImageAt {
269 message_index: usize,
270 image_index: usize,
271 },
272}
273
274/// Bare key event — deliberately smaller than crossterm's `KeyEvent`
275/// so the reducer doesn't depend on crossterm. The app event source
276/// does the conversion.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct Key {
279 pub code: KeyCode,
280 pub modifiers: KeyMods,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum KeyCode {
285 Char(char),
286 Enter,
287 Escape,
288 Backspace,
289 Delete,
290 Tab,
291 BackTab,
292 Left,
293 Right,
294 Up,
295 Down,
296 Home,
297 End,
298 PageUp,
299 PageDown,
300 F(u8),
301 /// Anything we don't care about (media keys, etc.).
302 Unknown,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
306pub struct KeyMods {
307 pub ctrl: bool,
308 pub alt: bool,
309 pub shift: bool,
310}
311
312impl KeyMods {
313 pub const NONE: Self = Self {
314 ctrl: false,
315 alt: false,
316 shift: false,
317 };
318
319 pub const fn ctrl() -> Self {
320 Self {
321 ctrl: true,
322 ..Self::NONE
323 }
324 }
325
326 pub const fn alt() -> Self {
327 Self {
328 alt: true,
329 ..Self::NONE
330 }
331 }
332
333 pub fn is_empty(self) -> bool {
334 !self.ctrl && !self.alt && !self.shift
335 }
336}
337
338/// Paste payload. Images come in as raw bytes; text as UTF-8.
339#[derive(Debug, Clone)]
340pub enum Paste {
341 Text(String),
342 Image { bytes: Vec<u8>, format: String },
343}
344
345/// Slash commands — a typed surface over what the user typed as
346/// `/<name> [args]`. Parsed in `app::event_source` against the single
347/// `COMMAND_REGISTRY`; unknown commands produce `SlashCmd::Unknown`
348/// so the reducer can issue a "no such command" status line.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub enum SlashCmd {
351 /// No arg → show current; `Some` → switch (and pull if needed).
352 Model(Option<String>),
353 Reasoning(Option<ReasoningLevel>),
354 VisibleReasoning(Option<String>),
355 /// No arg → show current safety mode; `Some` → switch it for this
356 /// session (`Shift+Tab` cycles the same field). Session-scoped.
357 Safety(Option<SafetyMode>),
358 Clear,
359 Save(Option<String>),
360 Load(Option<String>),
361 List,
362 Usage,
363 Context,
364 Compact(Option<String>),
365 /// List saved durable memories.
366 Memory,
367 /// Save free-text as a private memory.
368 Remember(Option<String>),
369 /// Delete a memory by name/id.
370 Forget(Option<String>),
371 /// Prune duplicate/obsolete memories via a one-shot model pass.
372 ConsolidateMemory,
373 Doctor,
374 Tasks,
375 Task(Option<String>),
376 Pause(Option<String>),
377 Resume(Option<String>),
378 Cancel(Option<String>),
379 Handoff(Option<String>),
380 Report(Option<String>),
381 Processes,
382 Logs(Option<String>),
383 Stop(Option<String>),
384 Restart(Option<String>),
385 Open(Option<String>),
386 Ports,
387 Approvals,
388 Approve(Option<String>),
389 Deny(Option<String>),
390 Checkpoint(Option<String>),
391 Checkpoints,
392 Restore(Option<String>),
393 ModelInfo(Option<String>),
394 Plugins,
395 CloudSetup,
396 Help,
397 Quit,
398 /// User typed something that isn't in the registry; carries the
399 /// raw name for the error message.
400 Unknown(String),
401}
402
403impl Msg {
404 /// Extract the `TurnId` for effect-result variants. Returns `None`
405 /// for variants that aren't turn-scoped (user intent,
406 /// housekeeping, MCP lifecycle). The reducer uses this to
407 /// short-circuit stale events.
408 pub fn turn_id(&self) -> Option<TurnId> {
409 match self {
410 Msg::StreamText { turn, .. }
411 | Msg::StreamReasoning { turn, .. }
412 | Msg::StreamToolCall { turn, .. }
413 | Msg::ContextUsageEstimated { turn, .. }
414 | Msg::CompactionFinished { turn, .. }
415 | Msg::CompactionFailed { turn, .. }
416 | Msg::StreamDone { turn, .. }
417 | Msg::UpstreamError { turn, .. }
418 | Msg::ToolStarted { turn, .. }
419 | Msg::ToolProgress { turn, .. }
420 | Msg::ToolFinished { turn, .. }
421 | Msg::ApprovalRequested { turn, .. } => Some(*turn),
422 Msg::TurnCancelled(turn) => Some(*turn),
423 _ => None,
424 }
425 }
426
427 /// Classification for telemetry / replay tooling. Cheaper than a
428 /// full `Debug` string and stable across refactors.
429 pub fn kind(&self) -> MsgKind {
430 match self {
431 Msg::Key(_) => MsgKind::Key,
432 Msg::Paste(_) => MsgKind::Paste,
433 Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
434 Msg::Slash(_) => MsgKind::Slash,
435 Msg::CancelTurn => MsgKind::CancelTurn,
436 Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
437 Msg::Quit => MsgKind::Quit,
438 Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
439 Msg::StreamText { .. } => MsgKind::StreamText,
440 Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
441 Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
442 Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
443 Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
444 Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
445 Msg::StreamDone { .. } => MsgKind::StreamDone,
446 Msg::UpstreamError { .. } => MsgKind::UpstreamError,
447 Msg::ToolStarted { .. } => MsgKind::ToolStarted,
448 Msg::ToolProgress { .. } => MsgKind::ToolProgress,
449 Msg::ToolFinished { .. } => MsgKind::ToolFinished,
450 Msg::ApprovalRequested { .. } => MsgKind::ApprovalRequested,
451 Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
452 Msg::McpServerReady { .. }
453 | Msg::McpServerErrored { .. }
454 | Msg::McpServerStopped { .. } => MsgKind::Mcp,
455 Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
456 Msg::MemoryChanged(_) => MsgKind::MemoryChanged,
457 Msg::SessionSaved => MsgKind::SessionSaved,
458 Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
459 Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
460 Msg::RuntimeTasksListed(_)
461 | Msg::RuntimeTaskLoaded { .. }
462 | Msg::RuntimeProcessesListed(_)
463 | Msg::RuntimeText(_)
464 | Msg::RuntimeApprovalsListed(_)
465 | Msg::RuntimeCheckpointsListed(_)
466 | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
467 Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
468 Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
469 Msg::Tick => MsgKind::Tick,
470 Msg::StatusDismiss => MsgKind::StatusDismiss,
471 Msg::Resize { .. } => MsgKind::Resize,
472 Msg::MouseScroll { .. } => MsgKind::MouseScroll,
473 Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
474 Msg::TransientStatus { .. } => MsgKind::TransientStatus,
475 }
476 }
477}
478
479/// Compact kind tag for tracing / replay indexing.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum MsgKind {
482 Key,
483 Paste,
484 SubmitPrompt,
485 Slash,
486 CancelTurn,
487 Confirm,
488 Quit,
489 RuntimeSignal,
490 StreamText,
491 StreamReasoning,
492 StreamToolCall,
493 ContextUsageEstimated,
494 CompactionFinished,
495 CompactionFailed,
496 StreamDone,
497 UpstreamError,
498 ToolStarted,
499 ToolProgress,
500 ToolFinished,
501 ApprovalRequested,
502 TurnCancelled,
503 Mcp,
504 InstructionsChanged,
505 MemoryChanged,
506 SessionSaved,
507 ConversationLoaded,
508 ConversationsListed,
509 RuntimeStore,
510 ModelPullFinished,
511 ModelPullProgress,
512 Tick,
513 StatusDismiss,
514 Resize,
515 MouseScroll,
516 OpenImageAt,
517 TransientStatus,
518}
519
520/// Helper for `app::event_source` — pass through the MCP config that
521/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
522/// Not a `Msg` because it's startup-only.
523#[derive(Debug, Clone)]
524pub struct StartupConfig {
525 pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
526 pub cwd: PathBuf,
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn turn_id_extracted_from_stream_messages() {
535 let m = Msg::StreamText {
536 turn: TurnId(7),
537 chunk: "hi".to_string(),
538 };
539 assert_eq!(m.turn_id(), Some(TurnId(7)));
540 }
541
542 #[test]
543 fn turn_id_none_for_user_intent() {
544 let m = Msg::CancelTurn;
545 assert_eq!(m.turn_id(), None);
546 let m = Msg::Quit;
547 assert_eq!(m.turn_id(), None);
548 let m = Msg::Tick;
549 assert_eq!(m.turn_id(), None);
550 }
551
552 #[test]
553 fn turn_id_none_for_mcp_lifecycle() {
554 let m = Msg::McpServerReady {
555 name: "s".to_string(),
556 tools: vec![],
557 };
558 assert_eq!(m.turn_id(), None);
559 }
560
561 #[test]
562 fn key_mods_builder_defaults_match_const() {
563 assert_eq!(KeyMods::default(), KeyMods::NONE);
564 assert!(KeyMods::ctrl().ctrl);
565 assert!(!KeyMods::ctrl().alt);
566 assert!(!KeyMods::ctrl().shift);
567 }
568
569 #[test]
570 fn kind_stable_across_variants() {
571 assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
572 assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
573 assert_eq!(
574 Msg::StreamText {
575 turn: TurnId(1),
576 chunk: String::new()
577 }
578 .kind(),
579 MsgKind::StreamText
580 );
581 }
582
583 #[test]
584 fn slash_cmd_carries_none_for_no_arg() {
585 let c = SlashCmd::Model(None);
586 assert_eq!(c, SlashCmd::Model(None));
587 assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
588 }
589}