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}