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),
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 serde::{Deserialize, Serialize};
23
24use crate::app::McpServerConfig;
25use crate::app::instructions::LoadedInstructions;
26use crate::models::tool_call::ToolCall as ModelToolCall;
27use crate::models::{FinishReason, ReasoningChunk, ReasoningLevel, TokenUsage, UserFacingError};
28use crate::runtime::{
29    ApprovalRecord, CheckpointRecord, PluginInstallRecord, ProcessRecord, SafetyMode, TaskRecord,
30    TaskTimelineEvent,
31};
32
33use super::ids::{ToolCallId, TurnId};
34use super::runtime::RuntimeSignal;
35use super::state::ContextUsageSnapshot;
36use super::state::StatusKind;
37use super::state::{ApprovalKind, ConversationSummary, McpToolSpec, ToolOutcome};
38use super::{CompactionResult, CompactionTrigger};
39
40/// Single reducer input. Non-exhaustive is intentional: adding a new
41/// variant is a deliberate act that forces every reducer arm to
42/// consider it at compile time (the reducer's match is NOT
43/// `_ =>` — see `reducer.rs`).
44///
45/// Serde derives exist for `--record` / `--replay`: every `Msg` the driver
46/// feeds the reducer is serialized to one JSONL line, and replay folds the
47/// deserialized stream back through `update()`. New variants round-trip
48/// automatically via the externally-tagged representation — no per-variant
49/// recording code to keep in sync.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[allow(clippy::large_enum_variant)]
52pub enum Msg {
53    // ── User intent ─────────────────────────────────────────────────
54    /// Raw key event from crossterm, after the event source has
55    /// stripped mouse/resize/paste.
56    Key(Key),
57    /// A full paste (text OR image) from the terminal.
58    Paste(Paste),
59    /// User hit Enter on a non-empty input. The event source has
60    /// already stripped the slash-command routing.
61    SubmitPrompt {
62        text: String,
63        /// Attachment IDs the reducer should consume from state.
64        attachment_ids: Vec<u64>,
65    },
66    /// User ran a slash command (post-routing from `app::event_source`).
67    Slash(SlashCmd),
68    /// Esc or another explicit cancellation source during an active turn.
69    CancelTurn,
70    /// Confirmation modal answer.
71    ConfirmAccepted,
72    ConfirmDeclined,
73    /// User wants to exit cleanly (Ctrl+D with empty input, or `/quit`).
74    Quit,
75    /// External process lifecycle signal. In raw-mode TUI sessions a
76    /// typed Ctrl+C still arrives as `Msg::Key`; this variant covers
77    /// OS-level SIGINT/SIGTERM/SIGHUP delivered from outside.
78    RuntimeSignal(RuntimeSignal),
79
80    // ── Streaming (from effect::model) ──────────────────────────────
81    /// Chunk of assistant text. Append to `partial_text`.
82    StreamText {
83        turn: TurnId,
84        chunk: String,
85    },
86    /// Chunk of reasoning / thinking content.
87    StreamReasoning {
88        turn: TurnId,
89        chunk: ReasoningChunk,
90    },
91    /// Model emitted a tool call. Append to the outgoing call list;
92    /// actual execution dispatches on `StreamDone`.
93    StreamToolCall {
94        turn: TurnId,
95        call: ModelToolCall,
96    },
97    /// Effect runner estimated the fully-enriched request context
98    /// after built-in and MCP tool schemas were attached.
99    ContextUsageEstimated {
100        turn: TurnId,
101        snapshot: ContextUsageSnapshot,
102    },
103    /// Effect runner resolved the provider's context window. For Ollama,
104    /// `model_max` is the probed architectural window and `effective` is the
105    /// auto-fitted/overridden `num_ctx`. Model-level metadata (not turn-scoped);
106    /// `model_id` is carried so a probe that lands after a `/model` switch can
107    /// be dropped instead of overwriting the new model's window. Drives the
108    /// `/context` display + quick-fix.
109    ProviderContextResolved {
110        model_id: String,
111        model_max: Option<usize>,
112        effective: Option<usize>,
113        source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
114    },
115    /// Effect runner verified the loaded model's memory placement after a turn
116    /// (Ollama `/api/ps`). `size_vram_bytes < total_bytes` ⇒ the model spilled
117    /// to CPU/RAM (slow). Model-level metadata (not turn-scoped); `model_id` is
118    /// carried so a probe that lands after a `/model` switch can be dropped.
119    /// Drives the offload warning + `/context` placement line.
120    OllamaPlacementResolved {
121        model_id: String,
122        size_vram_bytes: u64,
123        total_bytes: u64,
124        /// Auto-converge target: largest `num_ctx` that would fit when the model
125        /// spilled, or `None` if it fits / can't be helped by shrinking.
126        suggested_num_ctx: Option<u32>,
127    },
128    /// The effect runner's estimate of the built-in tool-schema token cost
129    /// it appends to every request during dispatch. Not turn-scoped — the
130    /// reducer stores it on `runtime` so `/context` can fold it into its
131    /// MCP-only estimate and agree with what dispatch actually decides.
132    BuiltinToolSchemaTokens(usize),
133    /// Context compaction completed and produced a replacement
134    /// model-visible history.
135    CompactionFinished {
136        turn: TurnId,
137        result: CompactionResult,
138    },
139    /// Context compaction failed or no-oped. Manual failures end the
140    /// compaction turn; auto failures may leave generation running.
141    CompactionFailed {
142        turn: TurnId,
143        trigger: CompactionTrigger,
144        message: String,
145        kind: StatusKind,
146    },
147    /// Stream complete. Carries final token count (0 if unknown) and,
148    /// for Anthropic, the thinking signature that must round-trip on
149    /// the next request.
150    StreamDone {
151        turn: TurnId,
152        usage: Option<TokenUsage>,
153        thinking_signature: Option<String>,
154        /// Why the model stopped (truncation / content block / normal), when
155        /// the provider reported it. Drives the truncation status note.
156        stop_reason: Option<FinishReason>,
157    },
158    /// Upstream returned a recoverable or terminal error. Reducer
159    /// commits an error line and returns to `Idle` (or surfaces a
160    /// retry affordance, if `recoverable`).
161    UpstreamError {
162        turn: TurnId,
163        error: UserFacingError,
164    },
165    /// Terminal event for a cancelled turn. Emitted by the effect
166    /// runner's `drop_scope` once every child task in the turn's
167    /// `TurnScope` has unwound. Reducer transitions
168    /// `Cancelling(id) → Idle` when it arrives.
169    ///
170    /// Without this, the reducer relies on the (wrong) side-channel of
171    /// `UpstreamError` arriving from a cancelled provider call to exit
172    /// `Cancelling`. If the provider task is aborted before it can
173    /// emit an error, the state would stick in `Cancelling` forever.
174    TurnCancelled(TurnId),
175
176    // ── Tools (from effect::tool) ───────────────────────────────────
177    /// Tool was picked up by the executor — useful for "spinner
178    /// started" UI transitions.
179    ToolStarted {
180        turn: TurnId,
181        call_id: ToolCallId,
182    },
183    /// Mid-flight progress (streaming subprocess output, byte-count
184    /// updates, multimodal artifacts, nested subagent activity).
185    /// Reducer pattern-matches the variant and routes accordingly:
186    /// text variants update the status line; `Artifact` with an
187    /// `image/*` mime attaches to the in-flight assistant message;
188    /// `Subagent*` variants render as indented status.
189    ToolProgress {
190        turn: TurnId,
191        call_id: ToolCallId,
192        event: crate::providers::ProgressEvent,
193    },
194    /// Tool finished (one of Finished / Error / Cancelled).
195    ToolFinished {
196        turn: TurnId,
197        call_id: ToolCallId,
198        outcome: ToolOutcome,
199    },
200    /// A gated tool is awaiting the user's inline approval (interactive
201    /// `ask` mode / Auto-mode escalation). The reducer enqueues a modal; the
202    /// answer flows back as `Cmd::ResolveApproval`. The tool task is parked
203    /// until then, so the turn naturally pauses (its outcome slot stays
204    /// `None`).
205    ApprovalRequested {
206        turn: TurnId,
207        call_id: ToolCallId,
208        tool: String,
209        risk: String,
210        kind: ApprovalKind,
211        prompt: String,
212        allowlist_scope: String,
213    },
214
215    // ── MCP (from effect::mcp) ──────────────────────────────────────
216    /// `initialize` succeeded; server is ready to dispatch tools.
217    McpServerReady {
218        name: String,
219        tools: Vec<McpToolSpec>,
220    },
221    /// Server startup failed OR the child exited with non-zero.
222    McpServerErrored {
223        name: String,
224        reason: String,
225    },
226    McpServerStopped {
227        name: String,
228    },
229
230    // ── Persistence (from effect::persistence) ──────────────────────
231    /// `MERMAID.md` loaded / changed / removed since last check.
232    InstructionsChanged(Option<LoadedInstructions>),
233    /// Memory files loaded / changed / removed since last check.
234    MemoryChanged(Option<crate::app::memory::LoadedMemory>),
235    /// `save_conversation` finished.
236    SessionSaved,
237    /// `/load <id>` — a saved conversation has been read off disk.
238    ConversationLoaded(crate::session::ConversationHistory),
239    /// Response to `Cmd::ListConversations`. Populates the `/load`
240    /// picker's candidate list.
241    ConversationsListed(Vec<ConversationSummary>),
242    /// Response to `/tasks`.
243    RuntimeTasksListed(Vec<TaskRecord>),
244    /// Response to `/task <id>`.
245    RuntimeTaskLoaded {
246        task: Option<TaskRecord>,
247        events: Vec<TaskTimelineEvent>,
248    },
249    /// Response to `/processes`.
250    RuntimeProcessesListed(Vec<ProcessRecord>),
251    /// Generic daemon/runtime text response.
252    RuntimeText(String),
253    RuntimeApprovalsListed(Vec<ApprovalRecord>),
254    RuntimeCheckpointsListed(Vec<CheckpointRecord>),
255    RuntimePluginsListed(Vec<PluginInstallRecord>),
256
257    // ── Misc model operations ───────────────────────────────────────
258    /// `/model <name>` finished pulling (Ollama only).
259    ModelPullFinished {
260        model: String,
261    },
262    /// Streaming stdout line from an `ollama pull` subprocess.
263    /// Reducer forwards to the status line for the user to watch.
264    ModelPullProgress(String),
265
266    // ── Housekeeping ────────────────────────────────────────────────
267    /// 1/60s timer tick. Used for spinner animation + elapsed-time
268    /// display. Reducer only advances derived fields.
269    Tick,
270    /// Terminal was resized. Reducer normally no-ops; render consumes.
271    Resize {
272        width: u16,
273        height: u16,
274    },
275
276    // ── Status feedback from async effects ─────────────────────────
277    /// Generic user-visible feedback from an async effect handler, routed into
278    /// the chat transcript. Lets effects surface a result (clipboard read,
279    /// config saved, plugin install, …) without a bespoke Msg per effect.
280    TransientStatus {
281        text: String,
282    },
283
284    // ── Mouse (F13) ─────────────────────────────────────────────────
285    /// Mouse-wheel scroll in the chat pane. Positive delta = scroll
286    /// toward older messages (up), negative = toward newer (down). The
287    /// reducer accumulates into `ui.mouse_scroll_accum`; the render layer
288    /// diffs it and applies the delta to the ChatWidget's scroll offset.
289    MouseScroll {
290        delta: i16,
291    },
292    /// Ctrl+Click on an image thumbnail in the chat pane. The
293    /// coordinates are absolute screen row/col; the render cache's
294    /// `ChatState::find_image_at_screen_pos` maps them to a
295    /// `(message_index, image_index)` pair. The main loop handles the
296    /// lookup before forwarding this message to the reducer, so by
297    /// the time the reducer sees it, the target has already been
298    /// resolved into a base64 payload and this Msg carries the
299    /// already-decoded image. The reducer just emits
300    /// `Cmd::WriteImageToTemp` + `Cmd::OpenInSystem`.
301    OpenImageAt {
302        message_index: usize,
303        image_index: usize,
304    },
305    /// Copy the current chat text selection to the system clipboard. The main
306    /// loop reads the selected text from the render layer (`rstate.chat`) and
307    /// emits this, so the side effect flows through `update()` — and is recorded
308    /// for replay — instead of being dispatched out-of-band (#18).
309    CopySelection(String),
310}
311
312/// Bare key event — deliberately smaller than crossterm's `KeyEvent`
313/// so the reducer doesn't depend on crossterm. The app event source
314/// does the conversion.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316pub struct Key {
317    pub code: KeyCode,
318    pub modifiers: KeyMods,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322pub enum KeyCode {
323    Char(char),
324    Enter,
325    Escape,
326    Backspace,
327    Delete,
328    Tab,
329    BackTab,
330    Left,
331    Right,
332    Up,
333    Down,
334    Home,
335    End,
336    PageUp,
337    PageDown,
338    F(u8),
339    /// Anything we don't care about (media keys, etc.).
340    Unknown,
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
344pub struct KeyMods {
345    pub ctrl: bool,
346    pub alt: bool,
347    pub shift: bool,
348}
349
350impl KeyMods {
351    pub const NONE: Self = Self {
352        ctrl: false,
353        alt: false,
354        shift: false,
355    };
356
357    pub const fn ctrl() -> Self {
358        Self {
359            ctrl: true,
360            ..Self::NONE
361        }
362    }
363
364    pub const fn alt() -> Self {
365        Self {
366            alt: true,
367            ..Self::NONE
368        }
369    }
370
371    pub fn is_empty(self) -> bool {
372        !self.ctrl && !self.alt && !self.shift
373    }
374}
375
376/// Paste payload. Images come in as raw bytes; text as UTF-8. Image bytes
377/// serialize as base64 so a recorded session replays pasted images
378/// bit-exactly without a numbers-array blowup in the JSONL line.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub enum Paste {
381    Text(String),
382    Image {
383        #[serde(with = "crate::utils::serde_base64")]
384        bytes: Vec<u8>,
385        format: String,
386    },
387}
388
389/// `/context` subcommands. No-arg shows the window; the rest tune the Ollama
390/// context window (per-model, persisted), mirroring `/reasoning`.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub enum ContextCmd {
393    /// Show the current window + usage (no arg).
394    Show,
395    /// `/context <n>` — set a per-model `num_ctx` override.
396    Set(u32),
397    /// `/context auto` — clear the override, return to auto-fit.
398    Auto,
399    /// `/context max` — use the model's full advertised window.
400    Max,
401    /// `/context offload on|off` — toggle Ollama RAM offload.
402    Offload(bool),
403}
404
405/// Slash commands — a typed surface over what the user typed as
406/// `/<name> [args]`. Parsed in `app::event_source` against the single
407/// `COMMAND_REGISTRY`; unknown commands produce `SlashCmd::Unknown`
408/// so the reducer can issue a "no such command" status line.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub enum SlashCmd {
411    /// No arg → show current; `Some` → switch (and pull if needed).
412    Model(Option<String>),
413    Reasoning(Option<ReasoningLevel>),
414    VisibleReasoning(Option<String>),
415    /// No arg → show current safety mode; `Some` → switch it for this
416    /// session (`Shift+Tab` cycles the same field). Session-scoped.
417    Safety(Option<SafetyMode>),
418    Clear,
419    Save(Option<String>),
420    Load(Option<String>),
421    List,
422    Usage,
423    Context(ContextCmd),
424    Compact(Option<String>),
425    /// List saved durable memories.
426    Memory,
427    /// Save free-text as a private memory.
428    Remember(Option<String>),
429    /// Delete a memory by name/id.
430    Forget(Option<String>),
431    /// Prune duplicate/obsolete memories via a one-shot model pass.
432    ConsolidateMemory,
433    Doctor,
434    Tasks,
435    Task(Option<String>),
436    Pause(Option<String>),
437    Resume(Option<String>),
438    Cancel(Option<String>),
439    Handoff(Option<String>),
440    Report(Option<String>),
441    Processes,
442    Logs(Option<String>),
443    Stop(Option<String>),
444    Restart(Option<String>),
445    Open(Option<String>),
446    Ports,
447    Approvals,
448    Approve(Option<String>),
449    Deny(Option<String>),
450    Checkpoint(Option<String>),
451    Checkpoints,
452    Restore(Option<String>),
453    ModelInfo(Option<String>),
454    Plugins,
455    CloudSetup,
456    Help,
457    Quit,
458    /// User typed something that isn't in the registry; carries the
459    /// raw name for the error message.
460    Unknown(String),
461}
462
463impl Msg {
464    /// Extract the `TurnId` for effect-result variants. Returns `None`
465    /// for variants that aren't turn-scoped (user intent,
466    /// housekeeping, MCP lifecycle). The reducer uses this to
467    /// short-circuit stale events.
468    pub fn turn_id(&self) -> Option<TurnId> {
469        match self {
470            Msg::StreamText { turn, .. }
471            | Msg::StreamReasoning { turn, .. }
472            | Msg::StreamToolCall { turn, .. }
473            | Msg::ContextUsageEstimated { turn, .. }
474            | Msg::CompactionFinished { turn, .. }
475            | Msg::CompactionFailed { turn, .. }
476            | Msg::StreamDone { turn, .. }
477            | Msg::UpstreamError { turn, .. }
478            | Msg::ToolStarted { turn, .. }
479            | Msg::ToolProgress { turn, .. }
480            | Msg::ToolFinished { turn, .. }
481            | Msg::ApprovalRequested { turn, .. } => Some(*turn),
482            Msg::TurnCancelled(turn) => Some(*turn),
483            _ => None,
484        }
485    }
486
487    /// Classification for telemetry / replay tooling. Cheaper than a
488    /// full `Debug` string and stable across refactors.
489    pub fn kind(&self) -> MsgKind {
490        match self {
491            Msg::Key(_) => MsgKind::Key,
492            Msg::Paste(_) => MsgKind::Paste,
493            Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
494            Msg::Slash(_) => MsgKind::Slash,
495            Msg::CancelTurn => MsgKind::CancelTurn,
496            Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
497            Msg::Quit => MsgKind::Quit,
498            Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
499            Msg::StreamText { .. } => MsgKind::StreamText,
500            Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
501            Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
502            Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
503            Msg::ProviderContextResolved { .. } => MsgKind::ProviderContextResolved,
504            Msg::OllamaPlacementResolved { .. } => MsgKind::OllamaPlacementResolved,
505            Msg::BuiltinToolSchemaTokens(_) => MsgKind::BuiltinToolSchemaTokens,
506            Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
507            Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
508            Msg::StreamDone { .. } => MsgKind::StreamDone,
509            Msg::UpstreamError { .. } => MsgKind::UpstreamError,
510            Msg::ToolStarted { .. } => MsgKind::ToolStarted,
511            Msg::ToolProgress { .. } => MsgKind::ToolProgress,
512            Msg::ToolFinished { .. } => MsgKind::ToolFinished,
513            Msg::ApprovalRequested { .. } => MsgKind::ApprovalRequested,
514            Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
515            Msg::McpServerReady { .. }
516            | Msg::McpServerErrored { .. }
517            | Msg::McpServerStopped { .. } => MsgKind::Mcp,
518            Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
519            Msg::MemoryChanged(_) => MsgKind::MemoryChanged,
520            Msg::SessionSaved => MsgKind::SessionSaved,
521            Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
522            Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
523            Msg::RuntimeTasksListed(_)
524            | Msg::RuntimeTaskLoaded { .. }
525            | Msg::RuntimeProcessesListed(_)
526            | Msg::RuntimeText(_)
527            | Msg::RuntimeApprovalsListed(_)
528            | Msg::RuntimeCheckpointsListed(_)
529            | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
530            Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
531            Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
532            Msg::Tick => MsgKind::Tick,
533            Msg::Resize { .. } => MsgKind::Resize,
534            Msg::MouseScroll { .. } => MsgKind::MouseScroll,
535            Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
536            Msg::TransientStatus { .. } => MsgKind::TransientStatus,
537            Msg::CopySelection(_) => MsgKind::CopySelection,
538        }
539    }
540}
541
542/// Compact kind tag for tracing / replay indexing.
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub enum MsgKind {
545    Key,
546    Paste,
547    SubmitPrompt,
548    Slash,
549    CancelTurn,
550    Confirm,
551    Quit,
552    RuntimeSignal,
553    StreamText,
554    StreamReasoning,
555    StreamToolCall,
556    ContextUsageEstimated,
557    ProviderContextResolved,
558    OllamaPlacementResolved,
559    BuiltinToolSchemaTokens,
560    CompactionFinished,
561    CompactionFailed,
562    StreamDone,
563    UpstreamError,
564    ToolStarted,
565    ToolProgress,
566    ToolFinished,
567    ApprovalRequested,
568    TurnCancelled,
569    Mcp,
570    InstructionsChanged,
571    MemoryChanged,
572    SessionSaved,
573    ConversationLoaded,
574    ConversationsListed,
575    RuntimeStore,
576    ModelPullFinished,
577    ModelPullProgress,
578    Tick,
579    Resize,
580    MouseScroll,
581    OpenImageAt,
582    TransientStatus,
583    CopySelection,
584}
585
586/// Helper for `app::event_source` — pass through the MCP config that
587/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
588/// Not a `Msg` because it's startup-only.
589#[derive(Debug, Clone)]
590pub struct StartupConfig {
591    pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
592    pub cwd: PathBuf,
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn turn_id_extracted_from_stream_messages() {
601        let m = Msg::StreamText {
602            turn: TurnId(7),
603            chunk: "hi".to_string(),
604        };
605        assert_eq!(m.turn_id(), Some(TurnId(7)));
606    }
607
608    #[test]
609    fn turn_id_none_for_user_intent() {
610        let m = Msg::CancelTurn;
611        assert_eq!(m.turn_id(), None);
612        let m = Msg::Quit;
613        assert_eq!(m.turn_id(), None);
614        let m = Msg::Tick;
615        assert_eq!(m.turn_id(), None);
616    }
617
618    #[test]
619    fn turn_id_none_for_mcp_lifecycle() {
620        let m = Msg::McpServerReady {
621            name: "s".to_string(),
622            tools: vec![],
623        };
624        assert_eq!(m.turn_id(), None);
625    }
626
627    #[test]
628    fn key_mods_builder_defaults_match_const() {
629        assert_eq!(KeyMods::default(), KeyMods::NONE);
630        assert!(KeyMods::ctrl().ctrl);
631        assert!(!KeyMods::ctrl().alt);
632        assert!(!KeyMods::ctrl().shift);
633    }
634
635    #[test]
636    fn kind_stable_across_variants() {
637        assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
638        assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
639        assert_eq!(
640            Msg::StreamText {
641                turn: TurnId(1),
642                chunk: String::new()
643            }
644            .kind(),
645            MsgKind::StreamText
646        );
647    }
648
649    #[test]
650    fn slash_cmd_carries_none_for_no_arg() {
651        let c = SlashCmd::Model(None);
652        assert_eq!(c, SlashCmd::Model(None));
653        assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
654    }
655}