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}