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    /// Effect runner resolved the provider's context window. For Ollama,
96    /// `model_max` is the probed architectural window and `effective` is the
97    /// auto-fitted/overridden `num_ctx`. Model-level metadata (not turn-scoped);
98    /// `model_id` is carried so a probe that lands after a `/model` switch can
99    /// be dropped instead of overwriting the new model's window. Drives the
100    /// `/context` display + quick-fix.
101    ProviderContextResolved {
102        model_id: String,
103        model_max: Option<usize>,
104        effective: Option<usize>,
105        source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
106    },
107    /// Effect runner verified the loaded model's memory placement after a turn
108    /// (Ollama `/api/ps`). `size_vram_bytes < total_bytes` ⇒ the model spilled
109    /// to CPU/RAM (slow). Model-level metadata (not turn-scoped); `model_id` is
110    /// carried so a probe that lands after a `/model` switch can be dropped.
111    /// Drives the offload warning + `/context` placement line.
112    OllamaPlacementResolved {
113        model_id: String,
114        size_vram_bytes: u64,
115        total_bytes: u64,
116        /// Auto-converge target: largest `num_ctx` that would fit when the model
117        /// spilled, or `None` if it fits / can't be helped by shrinking.
118        suggested_num_ctx: Option<u32>,
119    },
120    /// The effect runner's estimate of the built-in tool-schema token cost
121    /// it appends to every request during dispatch. Not turn-scoped — the
122    /// reducer stores it on `runtime` so `/context` can fold it into its
123    /// MCP-only estimate and agree with what dispatch actually decides.
124    BuiltinToolSchemaTokens(usize),
125    /// Context compaction completed and produced a replacement
126    /// model-visible history.
127    CompactionFinished {
128        turn: TurnId,
129        result: CompactionResult,
130    },
131    /// Context compaction failed or no-oped. Manual failures end the
132    /// compaction turn; auto failures may leave generation running.
133    CompactionFailed {
134        turn: TurnId,
135        trigger: CompactionTrigger,
136        message: String,
137        kind: StatusKind,
138    },
139    /// Stream complete. Carries final token count (0 if unknown) and,
140    /// for Anthropic, the thinking signature that must round-trip on
141    /// the next request.
142    StreamDone {
143        turn: TurnId,
144        usage: Option<TokenUsage>,
145        thinking_signature: Option<String>,
146        /// Why the model stopped (truncation / content block / normal), when
147        /// the provider reported it. Drives the truncation status note.
148        stop_reason: Option<FinishReason>,
149    },
150    /// Upstream returned a recoverable or terminal error. Reducer
151    /// commits an error line and returns to `Idle` (or surfaces a
152    /// retry affordance, if `recoverable`).
153    UpstreamError {
154        turn: TurnId,
155        error: UserFacingError,
156    },
157    /// Terminal event for a cancelled turn. Emitted by the effect
158    /// runner's `drop_scope` once every child task in the turn's
159    /// `TurnScope` has unwound. Reducer transitions
160    /// `Cancelling(id) → Idle` when it arrives.
161    ///
162    /// Without this, the reducer relies on the (wrong) side-channel of
163    /// `UpstreamError` arriving from a cancelled provider call to exit
164    /// `Cancelling`. If the provider task is aborted before it can
165    /// emit an error, the state would stick in `Cancelling` forever.
166    TurnCancelled(TurnId),
167
168    // ── Tools (from effect::tool) ───────────────────────────────────
169    /// Tool was picked up by the executor — useful for "spinner
170    /// started" UI transitions.
171    ToolStarted {
172        turn: TurnId,
173        call_id: ToolCallId,
174    },
175    /// Mid-flight progress (streaming subprocess output, byte-count
176    /// updates, multimodal artifacts, nested subagent activity).
177    /// Reducer pattern-matches the variant and routes accordingly:
178    /// text variants update the status line; `Artifact` with an
179    /// `image/*` mime attaches to the in-flight assistant message;
180    /// `Subagent*` variants render as indented status.
181    ToolProgress {
182        turn: TurnId,
183        call_id: ToolCallId,
184        event: crate::providers::ProgressEvent,
185    },
186    /// Tool finished (one of Finished / Error / Cancelled).
187    ToolFinished {
188        turn: TurnId,
189        call_id: ToolCallId,
190        outcome: ToolOutcome,
191    },
192    /// A gated tool is awaiting the user's inline approval (interactive
193    /// `ask` mode / Auto-mode escalation). The reducer enqueues a modal; the
194    /// answer flows back as `Cmd::ResolveApproval`. The tool task is parked
195    /// until then, so the turn naturally pauses (its outcome slot stays
196    /// `None`).
197    ApprovalRequested {
198        turn: TurnId,
199        call_id: ToolCallId,
200        tool: String,
201        risk: String,
202        kind: ApprovalKind,
203        prompt: String,
204        allowlist_scope: String,
205    },
206
207    // ── MCP (from effect::mcp) ──────────────────────────────────────
208    /// `initialize` succeeded; server is ready to dispatch tools.
209    McpServerReady {
210        name: String,
211        tools: Vec<McpToolSpec>,
212    },
213    /// Server startup failed OR the child exited with non-zero.
214    McpServerErrored {
215        name: String,
216        reason: String,
217    },
218    McpServerStopped {
219        name: String,
220    },
221
222    // ── Persistence (from effect::persistence) ──────────────────────
223    /// `MERMAID.md` loaded / changed / removed since last check.
224    InstructionsChanged(Option<LoadedInstructions>),
225    /// Memory files loaded / changed / removed since last check.
226    MemoryChanged(Option<crate::app::memory::LoadedMemory>),
227    /// `save_conversation` finished.
228    SessionSaved,
229    /// `/load <id>` — a saved conversation has been read off disk.
230    ConversationLoaded(crate::session::ConversationHistory),
231    /// Response to `Cmd::ListConversations`. Populates the `/load`
232    /// picker's candidate list.
233    ConversationsListed(Vec<ConversationSummary>),
234    /// Response to `/tasks`.
235    RuntimeTasksListed(Vec<TaskRecord>),
236    /// Response to `/task <id>`.
237    RuntimeTaskLoaded {
238        task: Option<TaskRecord>,
239        events: Vec<TaskTimelineEvent>,
240    },
241    /// Response to `/processes`.
242    RuntimeProcessesListed(Vec<ProcessRecord>),
243    /// Generic daemon/runtime text response.
244    RuntimeText(String),
245    RuntimeApprovalsListed(Vec<ApprovalRecord>),
246    RuntimeCheckpointsListed(Vec<CheckpointRecord>),
247    RuntimePluginsListed(Vec<PluginInstallRecord>),
248
249    // ── Misc model operations ───────────────────────────────────────
250    /// `/model <name>` finished pulling (Ollama only).
251    ModelPullFinished {
252        model: String,
253    },
254    /// Streaming stdout line from an `ollama pull` subprocess.
255    /// Reducer forwards to the status line for the user to watch.
256    ModelPullProgress(String),
257
258    // ── Housekeeping ────────────────────────────────────────────────
259    /// 1/60s timer tick. Used for spinner animation + elapsed-time
260    /// display. Reducer only advances derived fields.
261    Tick,
262    /// Status line expired (self-clear) or user dismissed.
263    StatusDismiss,
264    /// Terminal was resized. Reducer normally no-ops; render consumes.
265    Resize {
266        width: u16,
267        height: u16,
268    },
269
270    // ── Status feedback from async effects ─────────────────────────
271    /// Set `state.status` to `(text, kind)` and schedule automatic
272    /// dismissal after `dismiss_ms`. Used by effect handlers that
273    /// need to surface user-visible feedback without a bespoke Msg
274    /// per effect — today that's clipboard-read success / failure
275    /// (F14), but the variant is general and other effects will reuse
276    /// it. Reducer handles this arm by setting `state.status` and
277    /// pushing `Cmd::DismissStatusAfter { ms: dismiss_ms }`.
278    TransientStatus {
279        text: String,
280        kind: super::state::StatusKind,
281        dismiss_ms: u64,
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 tracks the scroll offset on `ui.chat_scroll`; the
288    /// ChatWidget reads it during render.
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)]
316pub struct Key {
317    pub code: KeyCode,
318    pub modifiers: KeyMods,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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)]
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.
377#[derive(Debug, Clone)]
378pub enum Paste {
379    Text(String),
380    Image { bytes: Vec<u8>, format: String },
381}
382
383/// `/context` subcommands. No-arg shows the window; the rest tune the Ollama
384/// context window (per-model, persisted), mirroring `/reasoning`.
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub enum ContextCmd {
387    /// Show the current window + usage (no arg).
388    Show,
389    /// `/context <n>` — set a per-model `num_ctx` override.
390    Set(u32),
391    /// `/context auto` — clear the override, return to auto-fit.
392    Auto,
393    /// `/context max` — use the model's full advertised window.
394    Max,
395    /// `/context offload on|off` — toggle Ollama RAM offload.
396    Offload(bool),
397}
398
399/// Slash commands — a typed surface over what the user typed as
400/// `/<name> [args]`. Parsed in `app::event_source` against the single
401/// `COMMAND_REGISTRY`; unknown commands produce `SlashCmd::Unknown`
402/// so the reducer can issue a "no such command" status line.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum SlashCmd {
405    /// No arg → show current; `Some` → switch (and pull if needed).
406    Model(Option<String>),
407    Reasoning(Option<ReasoningLevel>),
408    VisibleReasoning(Option<String>),
409    /// No arg → show current safety mode; `Some` → switch it for this
410    /// session (`Shift+Tab` cycles the same field). Session-scoped.
411    Safety(Option<SafetyMode>),
412    Clear,
413    Save(Option<String>),
414    Load(Option<String>),
415    List,
416    Usage,
417    Context(ContextCmd),
418    Compact(Option<String>),
419    /// List saved durable memories.
420    Memory,
421    /// Save free-text as a private memory.
422    Remember(Option<String>),
423    /// Delete a memory by name/id.
424    Forget(Option<String>),
425    /// Prune duplicate/obsolete memories via a one-shot model pass.
426    ConsolidateMemory,
427    Doctor,
428    Tasks,
429    Task(Option<String>),
430    Pause(Option<String>),
431    Resume(Option<String>),
432    Cancel(Option<String>),
433    Handoff(Option<String>),
434    Report(Option<String>),
435    Processes,
436    Logs(Option<String>),
437    Stop(Option<String>),
438    Restart(Option<String>),
439    Open(Option<String>),
440    Ports,
441    Approvals,
442    Approve(Option<String>),
443    Deny(Option<String>),
444    Checkpoint(Option<String>),
445    Checkpoints,
446    Restore(Option<String>),
447    ModelInfo(Option<String>),
448    Plugins,
449    CloudSetup,
450    Help,
451    Quit,
452    /// User typed something that isn't in the registry; carries the
453    /// raw name for the error message.
454    Unknown(String),
455}
456
457impl Msg {
458    /// Extract the `TurnId` for effect-result variants. Returns `None`
459    /// for variants that aren't turn-scoped (user intent,
460    /// housekeeping, MCP lifecycle). The reducer uses this to
461    /// short-circuit stale events.
462    pub fn turn_id(&self) -> Option<TurnId> {
463        match self {
464            Msg::StreamText { turn, .. }
465            | Msg::StreamReasoning { turn, .. }
466            | Msg::StreamToolCall { turn, .. }
467            | Msg::ContextUsageEstimated { turn, .. }
468            | Msg::CompactionFinished { turn, .. }
469            | Msg::CompactionFailed { turn, .. }
470            | Msg::StreamDone { turn, .. }
471            | Msg::UpstreamError { turn, .. }
472            | Msg::ToolStarted { turn, .. }
473            | Msg::ToolProgress { turn, .. }
474            | Msg::ToolFinished { turn, .. }
475            | Msg::ApprovalRequested { turn, .. } => Some(*turn),
476            Msg::TurnCancelled(turn) => Some(*turn),
477            _ => None,
478        }
479    }
480
481    /// Classification for telemetry / replay tooling. Cheaper than a
482    /// full `Debug` string and stable across refactors.
483    pub fn kind(&self) -> MsgKind {
484        match self {
485            Msg::Key(_) => MsgKind::Key,
486            Msg::Paste(_) => MsgKind::Paste,
487            Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
488            Msg::Slash(_) => MsgKind::Slash,
489            Msg::CancelTurn => MsgKind::CancelTurn,
490            Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
491            Msg::Quit => MsgKind::Quit,
492            Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
493            Msg::StreamText { .. } => MsgKind::StreamText,
494            Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
495            Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
496            Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
497            Msg::ProviderContextResolved { .. } => MsgKind::ProviderContextResolved,
498            Msg::OllamaPlacementResolved { .. } => MsgKind::OllamaPlacementResolved,
499            Msg::BuiltinToolSchemaTokens(_) => MsgKind::BuiltinToolSchemaTokens,
500            Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
501            Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
502            Msg::StreamDone { .. } => MsgKind::StreamDone,
503            Msg::UpstreamError { .. } => MsgKind::UpstreamError,
504            Msg::ToolStarted { .. } => MsgKind::ToolStarted,
505            Msg::ToolProgress { .. } => MsgKind::ToolProgress,
506            Msg::ToolFinished { .. } => MsgKind::ToolFinished,
507            Msg::ApprovalRequested { .. } => MsgKind::ApprovalRequested,
508            Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
509            Msg::McpServerReady { .. }
510            | Msg::McpServerErrored { .. }
511            | Msg::McpServerStopped { .. } => MsgKind::Mcp,
512            Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
513            Msg::MemoryChanged(_) => MsgKind::MemoryChanged,
514            Msg::SessionSaved => MsgKind::SessionSaved,
515            Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
516            Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
517            Msg::RuntimeTasksListed(_)
518            | Msg::RuntimeTaskLoaded { .. }
519            | Msg::RuntimeProcessesListed(_)
520            | Msg::RuntimeText(_)
521            | Msg::RuntimeApprovalsListed(_)
522            | Msg::RuntimeCheckpointsListed(_)
523            | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
524            Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
525            Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
526            Msg::Tick => MsgKind::Tick,
527            Msg::StatusDismiss => MsgKind::StatusDismiss,
528            Msg::Resize { .. } => MsgKind::Resize,
529            Msg::MouseScroll { .. } => MsgKind::MouseScroll,
530            Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
531            Msg::TransientStatus { .. } => MsgKind::TransientStatus,
532            Msg::CopySelection(_) => MsgKind::CopySelection,
533        }
534    }
535}
536
537/// Compact kind tag for tracing / replay indexing.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum MsgKind {
540    Key,
541    Paste,
542    SubmitPrompt,
543    Slash,
544    CancelTurn,
545    Confirm,
546    Quit,
547    RuntimeSignal,
548    StreamText,
549    StreamReasoning,
550    StreamToolCall,
551    ContextUsageEstimated,
552    ProviderContextResolved,
553    OllamaPlacementResolved,
554    BuiltinToolSchemaTokens,
555    CompactionFinished,
556    CompactionFailed,
557    StreamDone,
558    UpstreamError,
559    ToolStarted,
560    ToolProgress,
561    ToolFinished,
562    ApprovalRequested,
563    TurnCancelled,
564    Mcp,
565    InstructionsChanged,
566    MemoryChanged,
567    SessionSaved,
568    ConversationLoaded,
569    ConversationsListed,
570    RuntimeStore,
571    ModelPullFinished,
572    ModelPullProgress,
573    Tick,
574    StatusDismiss,
575    Resize,
576    MouseScroll,
577    OpenImageAt,
578    TransientStatus,
579    CopySelection,
580}
581
582/// Helper for `app::event_source` — pass through the MCP config that
583/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
584/// Not a `Msg` because it's startup-only.
585#[derive(Debug, Clone)]
586pub struct StartupConfig {
587    pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
588    pub cwd: PathBuf,
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn turn_id_extracted_from_stream_messages() {
597        let m = Msg::StreamText {
598            turn: TurnId(7),
599            chunk: "hi".to_string(),
600        };
601        assert_eq!(m.turn_id(), Some(TurnId(7)));
602    }
603
604    #[test]
605    fn turn_id_none_for_user_intent() {
606        let m = Msg::CancelTurn;
607        assert_eq!(m.turn_id(), None);
608        let m = Msg::Quit;
609        assert_eq!(m.turn_id(), None);
610        let m = Msg::Tick;
611        assert_eq!(m.turn_id(), None);
612    }
613
614    #[test]
615    fn turn_id_none_for_mcp_lifecycle() {
616        let m = Msg::McpServerReady {
617            name: "s".to_string(),
618            tools: vec![],
619        };
620        assert_eq!(m.turn_id(), None);
621    }
622
623    #[test]
624    fn key_mods_builder_defaults_match_const() {
625        assert_eq!(KeyMods::default(), KeyMods::NONE);
626        assert!(KeyMods::ctrl().ctrl);
627        assert!(!KeyMods::ctrl().alt);
628        assert!(!KeyMods::ctrl().shift);
629    }
630
631    #[test]
632    fn kind_stable_across_variants() {
633        assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
634        assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
635        assert_eq!(
636            Msg::StreamText {
637                turn: TurnId(1),
638                chunk: String::new()
639            }
640            .kind(),
641            MsgKind::StreamText
642        );
643    }
644
645    #[test]
646    fn slash_cmd_carries_none_for_no_arg() {
647        let c = SlashCmd::Model(None);
648        assert_eq!(c, SlashCmd::Model(None));
649        assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
650    }
651}