Skip to main content

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}