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