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