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, 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::{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    Clear,
341    Save(Option<String>),
342    Load(Option<String>),
343    List,
344    Usage,
345    Context,
346    Compact(Option<String>),
347    Doctor,
348    Tasks,
349    Task(Option<String>),
350    Pause(Option<String>),
351    Resume(Option<String>),
352    Cancel(Option<String>),
353    Handoff(Option<String>),
354    Report(Option<String>),
355    Processes,
356    Logs(Option<String>),
357    Stop(Option<String>),
358    Restart(Option<String>),
359    Open(Option<String>),
360    Ports,
361    Approvals,
362    Approve(Option<String>),
363    Deny(Option<String>),
364    Checkpoint(Option<String>),
365    Checkpoints,
366    Restore(Option<String>),
367    Memory,
368    MemoryEdit(Option<String>),
369    Remember(Option<String>),
370    Forget(Option<String>),
371    ModelInfo(Option<String>),
372    Plugins,
373    CloudSetup,
374    Help,
375    Quit,
376    /// User typed something that isn't in the registry; carries the
377    /// raw name for the error message.
378    Unknown(String),
379}
380
381impl Msg {
382    /// Extract the `TurnId` for effect-result variants. Returns `None`
383    /// for variants that aren't turn-scoped (user intent,
384    /// housekeeping, MCP lifecycle). The reducer uses this to
385    /// short-circuit stale events.
386    pub fn turn_id(&self) -> Option<TurnId> {
387        match self {
388            Msg::StreamText { turn, .. }
389            | Msg::StreamReasoning { turn, .. }
390            | Msg::StreamToolCall { turn, .. }
391            | Msg::ContextUsageEstimated { turn, .. }
392            | Msg::CompactionFinished { turn, .. }
393            | Msg::CompactionFailed { turn, .. }
394            | Msg::StreamDone { turn, .. }
395            | Msg::UpstreamError { turn, .. }
396            | Msg::ToolStarted { turn, .. }
397            | Msg::ToolProgress { turn, .. }
398            | Msg::ToolFinished { turn, .. } => Some(*turn),
399            Msg::TurnCancelled(turn) => Some(*turn),
400            _ => None,
401        }
402    }
403
404    /// Classification for telemetry / replay tooling. Cheaper than a
405    /// full `Debug` string and stable across refactors.
406    pub fn kind(&self) -> MsgKind {
407        match self {
408            Msg::Key(_) => MsgKind::Key,
409            Msg::Paste(_) => MsgKind::Paste,
410            Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
411            Msg::Slash(_) => MsgKind::Slash,
412            Msg::CancelTurn => MsgKind::CancelTurn,
413            Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
414            Msg::Quit => MsgKind::Quit,
415            Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
416            Msg::StreamText { .. } => MsgKind::StreamText,
417            Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
418            Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
419            Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
420            Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
421            Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
422            Msg::StreamDone { .. } => MsgKind::StreamDone,
423            Msg::UpstreamError { .. } => MsgKind::UpstreamError,
424            Msg::ToolStarted { .. } => MsgKind::ToolStarted,
425            Msg::ToolProgress { .. } => MsgKind::ToolProgress,
426            Msg::ToolFinished { .. } => MsgKind::ToolFinished,
427            Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
428            Msg::McpServerReady { .. }
429            | Msg::McpServerErrored { .. }
430            | Msg::McpServerStopped { .. } => MsgKind::Mcp,
431            Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
432            Msg::SessionSaved => MsgKind::SessionSaved,
433            Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
434            Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
435            Msg::RuntimeTasksListed(_)
436            | Msg::RuntimeTaskLoaded { .. }
437            | Msg::RuntimeProcessesListed(_)
438            | Msg::RuntimeText(_)
439            | Msg::RuntimeApprovalsListed(_)
440            | Msg::RuntimeCheckpointsListed(_)
441            | Msg::RuntimeMemoryListed(_)
442            | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
443            Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
444            Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
445            Msg::Tick => MsgKind::Tick,
446            Msg::StatusDismiss => MsgKind::StatusDismiss,
447            Msg::Resize { .. } => MsgKind::Resize,
448            Msg::MouseScroll { .. } => MsgKind::MouseScroll,
449            Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
450            Msg::TransientStatus { .. } => MsgKind::TransientStatus,
451        }
452    }
453}
454
455/// Compact kind tag for tracing / replay indexing.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum MsgKind {
458    Key,
459    Paste,
460    SubmitPrompt,
461    Slash,
462    CancelTurn,
463    Confirm,
464    Quit,
465    RuntimeSignal,
466    StreamText,
467    StreamReasoning,
468    StreamToolCall,
469    ContextUsageEstimated,
470    CompactionFinished,
471    CompactionFailed,
472    StreamDone,
473    UpstreamError,
474    ToolStarted,
475    ToolProgress,
476    ToolFinished,
477    TurnCancelled,
478    Mcp,
479    InstructionsChanged,
480    SessionSaved,
481    ConversationLoaded,
482    ConversationsListed,
483    RuntimeStore,
484    ModelPullFinished,
485    ModelPullProgress,
486    Tick,
487    StatusDismiss,
488    Resize,
489    MouseScroll,
490    OpenImageAt,
491    TransientStatus,
492}
493
494/// Helper for `app::event_source` — pass through the MCP config that
495/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
496/// Not a `Msg` because it's startup-only.
497#[derive(Debug, Clone)]
498pub struct StartupConfig {
499    pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
500    pub cwd: PathBuf,
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn turn_id_extracted_from_stream_messages() {
509        let m = Msg::StreamText {
510            turn: TurnId(7),
511            chunk: "hi".to_string(),
512        };
513        assert_eq!(m.turn_id(), Some(TurnId(7)));
514    }
515
516    #[test]
517    fn turn_id_none_for_user_intent() {
518        let m = Msg::CancelTurn;
519        assert_eq!(m.turn_id(), None);
520        let m = Msg::Quit;
521        assert_eq!(m.turn_id(), None);
522        let m = Msg::Tick;
523        assert_eq!(m.turn_id(), None);
524    }
525
526    #[test]
527    fn turn_id_none_for_mcp_lifecycle() {
528        let m = Msg::McpServerReady {
529            name: "s".to_string(),
530            tools: vec![],
531        };
532        assert_eq!(m.turn_id(), None);
533    }
534
535    #[test]
536    fn key_mods_builder_defaults_match_const() {
537        assert_eq!(KeyMods::default(), KeyMods::NONE);
538        assert!(KeyMods::ctrl().ctrl);
539        assert!(!KeyMods::ctrl().alt);
540        assert!(!KeyMods::ctrl().shift);
541    }
542
543    #[test]
544    fn kind_stable_across_variants() {
545        assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
546        assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
547        assert_eq!(
548            Msg::StreamText {
549                turn: TurnId(1),
550                chunk: String::new()
551            }
552            .kind(),
553            MsgKind::StreamText
554        );
555    }
556
557    #[test]
558    fn slash_cmd_carries_none_for_no_arg() {
559        let c = SlashCmd::Model(None);
560        assert_eq!(c, SlashCmd::Model(None));
561        assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
562    }
563}