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