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};
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}