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, SafetyMode,
28 TaskRecord, 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 /// No arg → show current safety mode; `Some` → switch it for this
341 /// session (`Shift+Tab` cycles the same field). Session-scoped.
342 Safety(Option<SafetyMode>),
343 Clear,
344 Save(Option<String>),
345 Load(Option<String>),
346 List,
347 Usage,
348 Context,
349 Compact(Option<String>),
350 Doctor,
351 Tasks,
352 Task(Option<String>),
353 Pause(Option<String>),
354 Resume(Option<String>),
355 Cancel(Option<String>),
356 Handoff(Option<String>),
357 Report(Option<String>),
358 Processes,
359 Logs(Option<String>),
360 Stop(Option<String>),
361 Restart(Option<String>),
362 Open(Option<String>),
363 Ports,
364 Approvals,
365 Approve(Option<String>),
366 Deny(Option<String>),
367 Checkpoint(Option<String>),
368 Checkpoints,
369 Restore(Option<String>),
370 Memory,
371 MemoryEdit(Option<String>),
372 Remember(Option<String>),
373 Forget(Option<String>),
374 ModelInfo(Option<String>),
375 Plugins,
376 CloudSetup,
377 Help,
378 Quit,
379 /// User typed something that isn't in the registry; carries the
380 /// raw name for the error message.
381 Unknown(String),
382}
383
384impl Msg {
385 /// Extract the `TurnId` for effect-result variants. Returns `None`
386 /// for variants that aren't turn-scoped (user intent,
387 /// housekeeping, MCP lifecycle). The reducer uses this to
388 /// short-circuit stale events.
389 pub fn turn_id(&self) -> Option<TurnId> {
390 match self {
391 Msg::StreamText { turn, .. }
392 | Msg::StreamReasoning { turn, .. }
393 | Msg::StreamToolCall { turn, .. }
394 | Msg::ContextUsageEstimated { turn, .. }
395 | Msg::CompactionFinished { turn, .. }
396 | Msg::CompactionFailed { turn, .. }
397 | Msg::StreamDone { turn, .. }
398 | Msg::UpstreamError { turn, .. }
399 | Msg::ToolStarted { turn, .. }
400 | Msg::ToolProgress { turn, .. }
401 | Msg::ToolFinished { turn, .. } => Some(*turn),
402 Msg::TurnCancelled(turn) => Some(*turn),
403 _ => None,
404 }
405 }
406
407 /// Classification for telemetry / replay tooling. Cheaper than a
408 /// full `Debug` string and stable across refactors.
409 pub fn kind(&self) -> MsgKind {
410 match self {
411 Msg::Key(_) => MsgKind::Key,
412 Msg::Paste(_) => MsgKind::Paste,
413 Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
414 Msg::Slash(_) => MsgKind::Slash,
415 Msg::CancelTurn => MsgKind::CancelTurn,
416 Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
417 Msg::Quit => MsgKind::Quit,
418 Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
419 Msg::StreamText { .. } => MsgKind::StreamText,
420 Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
421 Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
422 Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
423 Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
424 Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
425 Msg::StreamDone { .. } => MsgKind::StreamDone,
426 Msg::UpstreamError { .. } => MsgKind::UpstreamError,
427 Msg::ToolStarted { .. } => MsgKind::ToolStarted,
428 Msg::ToolProgress { .. } => MsgKind::ToolProgress,
429 Msg::ToolFinished { .. } => MsgKind::ToolFinished,
430 Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
431 Msg::McpServerReady { .. }
432 | Msg::McpServerErrored { .. }
433 | Msg::McpServerStopped { .. } => MsgKind::Mcp,
434 Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
435 Msg::SessionSaved => MsgKind::SessionSaved,
436 Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
437 Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
438 Msg::RuntimeTasksListed(_)
439 | Msg::RuntimeTaskLoaded { .. }
440 | Msg::RuntimeProcessesListed(_)
441 | Msg::RuntimeText(_)
442 | Msg::RuntimeApprovalsListed(_)
443 | Msg::RuntimeCheckpointsListed(_)
444 | Msg::RuntimeMemoryListed(_)
445 | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
446 Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
447 Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
448 Msg::Tick => MsgKind::Tick,
449 Msg::StatusDismiss => MsgKind::StatusDismiss,
450 Msg::Resize { .. } => MsgKind::Resize,
451 Msg::MouseScroll { .. } => MsgKind::MouseScroll,
452 Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
453 Msg::TransientStatus { .. } => MsgKind::TransientStatus,
454 }
455 }
456}
457
458/// Compact kind tag for tracing / replay indexing.
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
460pub enum MsgKind {
461 Key,
462 Paste,
463 SubmitPrompt,
464 Slash,
465 CancelTurn,
466 Confirm,
467 Quit,
468 RuntimeSignal,
469 StreamText,
470 StreamReasoning,
471 StreamToolCall,
472 ContextUsageEstimated,
473 CompactionFinished,
474 CompactionFailed,
475 StreamDone,
476 UpstreamError,
477 ToolStarted,
478 ToolProgress,
479 ToolFinished,
480 TurnCancelled,
481 Mcp,
482 InstructionsChanged,
483 SessionSaved,
484 ConversationLoaded,
485 ConversationsListed,
486 RuntimeStore,
487 ModelPullFinished,
488 ModelPullProgress,
489 Tick,
490 StatusDismiss,
491 Resize,
492 MouseScroll,
493 OpenImageAt,
494 TransientStatus,
495}
496
497/// Helper for `app::event_source` — pass through the MCP config that
498/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
499/// Not a `Msg` because it's startup-only.
500#[derive(Debug, Clone)]
501pub struct StartupConfig {
502 pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
503 pub cwd: PathBuf,
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn turn_id_extracted_from_stream_messages() {
512 let m = Msg::StreamText {
513 turn: TurnId(7),
514 chunk: "hi".to_string(),
515 };
516 assert_eq!(m.turn_id(), Some(TurnId(7)));
517 }
518
519 #[test]
520 fn turn_id_none_for_user_intent() {
521 let m = Msg::CancelTurn;
522 assert_eq!(m.turn_id(), None);
523 let m = Msg::Quit;
524 assert_eq!(m.turn_id(), None);
525 let m = Msg::Tick;
526 assert_eq!(m.turn_id(), None);
527 }
528
529 #[test]
530 fn turn_id_none_for_mcp_lifecycle() {
531 let m = Msg::McpServerReady {
532 name: "s".to_string(),
533 tools: vec![],
534 };
535 assert_eq!(m.turn_id(), None);
536 }
537
538 #[test]
539 fn key_mods_builder_defaults_match_const() {
540 assert_eq!(KeyMods::default(), KeyMods::NONE);
541 assert!(KeyMods::ctrl().ctrl);
542 assert!(!KeyMods::ctrl().alt);
543 assert!(!KeyMods::ctrl().shift);
544 }
545
546 #[test]
547 fn kind_stable_across_variants() {
548 assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
549 assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
550 assert_eq!(
551 Msg::StreamText {
552 turn: TurnId(1),
553 chunk: String::new()
554 }
555 .kind(),
556 MsgKind::StreamText
557 );
558 }
559
560 #[test]
561 fn slash_cmd_carries_none_for_no_arg() {
562 let c = SlashCmd::Model(None);
563 assert_eq!(c, SlashCmd::Model(None));
564 assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
565 }
566}