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