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