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