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),
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::{
28    FinishReason, ProviderContinuation, ReasoningChunk, ReasoningLevel, TokenUsage, UserFacingError,
29};
30use crate::runtime::{
31    ApprovalRecord, CheckpointRecord, PluginInstallRecord, ProcessRecord, SafetyMode, TaskRecord,
32    TaskTimelineEvent,
33};
34
35use super::ids::{ToolCallId, TurnId};
36use super::question::Question;
37use super::runtime::RuntimeSignal;
38use super::state::ContextUsageSnapshot;
39use super::state::StatusKind;
40use super::state::{ApprovalKind, ConversationSummary, McpToolSpec, ToolOutcome};
41use super::{CompactionResult, CompactionTrigger};
42
43/// Single reducer input. Non-exhaustive is intentional: adding a new
44/// variant is a deliberate act that forces every reducer arm to
45/// consider it at compile time (the reducer's match is NOT
46/// `_ =>` — see `reducer.rs`).
47///
48/// Serde derives exist for `--record` / `--replay`: every `Msg` the driver
49/// feeds the reducer is serialized to one JSONL line, and replay folds the
50/// deserialized stream back through `update()`. New variants round-trip
51/// automatically via the externally-tagged representation — no per-variant
52/// recording code to keep in sync.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[allow(clippy::large_enum_variant)]
55pub enum Msg {
56    // ── User intent ─────────────────────────────────────────────────
57    /// Raw key event from crossterm, after the event source has
58    /// stripped mouse/resize/paste.
59    Key(Key),
60    /// A terminal bracketed paste (always text; see [`Paste`]).
61    Paste(Paste),
62    /// Async result of a `Cmd::ReadClipboard` (Ctrl+V) — image, text, empty, or
63    /// error. Kept separate from [`Msg::Paste`] so the paste-race guard can
64    /// track exactly these reads (see [`ClipboardRead`]).
65    ClipboardRead(ClipboardRead),
66    /// User hit Enter on a non-empty input. The event source has
67    /// already stripped the slash-command routing.
68    SubmitPrompt {
69        text: String,
70        /// Attachment IDs the reducer should consume from state.
71        attachment_ids: Vec<u64>,
72    },
73    /// User ran a slash command (post-routing from `app::event_source`).
74    Slash(SlashCmd),
75    /// Esc or another explicit cancellation source during an active turn.
76    CancelTurn,
77    /// Confirmation modal answer.
78    ConfirmAccepted,
79    ConfirmDeclined,
80    /// User wants to exit cleanly (Ctrl+D with empty input, or `/quit`).
81    Quit,
82    /// External process lifecycle signal. In raw-mode TUI sessions a
83    /// typed Ctrl+C still arrives as `Msg::Key`; this variant covers
84    /// OS-level SIGINT/SIGTERM/SIGHUP delivered from outside.
85    RuntimeSignal(RuntimeSignal),
86
87    // ── Streaming (from effect::model) ──────────────────────────────
88    /// Chunk of assistant text. Append to `partial_text`.
89    StreamText {
90        turn: TurnId,
91        chunk: String,
92    },
93    /// Chunk of reasoning / thinking content.
94    StreamReasoning {
95        turn: TurnId,
96        chunk: ReasoningChunk,
97    },
98    /// Model emitted a tool call. Append to the outgoing call list;
99    /// actual execution dispatches on `StreamDone`.
100    StreamToolCall {
101        turn: TurnId,
102        call: ModelToolCall,
103    },
104    /// Effect runner estimated the fully-enriched request context
105    /// after built-in and MCP tool schemas were attached.
106    ContextUsageEstimated {
107        turn: TurnId,
108        snapshot: ContextUsageSnapshot,
109    },
110    /// Effect runner resolved the provider's context window. For Ollama,
111    /// `model_max` is the probed architectural window and `effective` is the
112    /// auto-fitted/overridden `num_ctx`. Model-level metadata (not turn-scoped);
113    /// `model_id` is carried so a probe that lands after a `/model` switch can
114    /// be dropped instead of overwriting the new model's window. Drives the
115    /// `/context` display + quick-fix.
116    ProviderContextResolved {
117        model_id: String,
118        model_max: Option<usize>,
119        effective: Option<usize>,
120        source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
121        /// The model's per-response output ceiling when the provider exposes
122        /// one (`/models` metadata / documented table). `#[serde(default)]` so
123        /// recordings from before this field replay unchanged.
124        #[serde(default)]
125        max_output: Option<usize>,
126    },
127    /// Effect runner verified the loaded model's memory placement after a turn
128    /// (Ollama `/api/ps`). `size_vram_bytes < total_bytes` ⇒ the model spilled
129    /// to CPU/RAM (slow). Model-level metadata (not turn-scoped); `model_id` is
130    /// carried so a probe that lands after a `/model` switch can be dropped.
131    /// Drives the offload warning + `/context` placement line.
132    OllamaPlacementResolved {
133        model_id: String,
134        size_vram_bytes: u64,
135        total_bytes: u64,
136        /// Auto-converge target: largest `num_ctx` that would fit when the model
137        /// spilled, or `None` if it fits / can't be helped by shrinking.
138        suggested_num_ctx: Option<u32>,
139    },
140    /// Effect runner probed whether the active model can see images (Ollama
141    /// `/api/show` `capabilities`). Model-level metadata (not turn-scoped);
142    /// `model_id` is carried so a probe that lands after a `/model` switch is
143    /// dropped. `warn` (set at the trigger site — an image paste, a `/model`
144    /// switch with an image staged, or a send carrying images) gates the
145    /// one-shot no-vision-model notice; the capability snapshot refreshes
146    /// regardless. `supports_vision` is `None` when unknown (non-Ollama, or the
147    /// probe failed) → never warn.
148    ProviderVisionResolved {
149        model_id: String,
150        supports_vision: Option<bool>,
151        warn: bool,
152    },
153    /// The effect runner's estimate of the built-in tool-schema token cost
154    /// it appends to every request during dispatch. Not turn-scoped — the
155    /// reducer stores it on `runtime` so `/context` can fold it into its
156    /// MCP-only estimate and agree with what dispatch actually decides.
157    BuiltinToolSchemaTokens(usize),
158    /// Context compaction completed and produced a replacement
159    /// model-visible history.
160    CompactionFinished {
161        turn: TurnId,
162        result: CompactionResult,
163    },
164    /// Context compaction failed or no-oped. Manual failures end the
165    /// compaction turn; auto failures may leave generation running.
166    CompactionFailed {
167        turn: TurnId,
168        trigger: CompactionTrigger,
169        message: String,
170        kind: StatusKind,
171    },
172    /// Stream complete. Carries final token count and opaque provider state
173    /// that must round-trip on the next request.
174    StreamDone {
175        turn: TurnId,
176        usage: Option<TokenUsage>,
177        provider_continuation: Option<ProviderContinuation>,
178        /// Why the model stopped (truncation / content block / normal), when
179        /// the provider reported it. Drives the truncation status note.
180        stop_reason: Option<FinishReason>,
181    },
182    /// Upstream returned a recoverable or terminal error. Reducer
183    /// commits an error line and returns to `Idle` (or surfaces a
184    /// retry affordance, if `recoverable`).
185    UpstreamError {
186        turn: TurnId,
187        error: UserFacingError,
188    },
189    /// Terminal event for a cancelled turn. Emitted by the effect
190    /// runner's `drop_scope` once every child task in the turn's
191    /// `TurnScope` has unwound. Reducer transitions
192    /// `Cancelling(id) → Idle` when it arrives.
193    ///
194    /// Without this, the reducer relies on the (wrong) side-channel of
195    /// `UpstreamError` arriving from a cancelled provider call to exit
196    /// `Cancelling`. If the provider task is aborted before it can
197    /// emit an error, the state would stick in `Cancelling` forever.
198    TurnCancelled(TurnId),
199
200    // ── Tools (from effect::tool) ───────────────────────────────────
201    /// Tool was picked up by the executor — useful for "spinner
202    /// started" UI transitions.
203    ToolStarted {
204        turn: TurnId,
205        call_id: ToolCallId,
206    },
207    /// Mid-flight progress (streaming subprocess output, byte-count
208    /// updates, multimodal artifacts, nested subagent activity).
209    /// Reducer pattern-matches the variant and routes accordingly:
210    /// text variants update the status line; `Artifact` with an
211    /// `image/*` mime attaches to the in-flight assistant message;
212    /// `Subagent*` variants render as indented status.
213    ToolProgress {
214        turn: TurnId,
215        call_id: ToolCallId,
216        event: crate::providers::ProgressEvent,
217    },
218    /// Tool finished (one of Finished / Error / Cancelled).
219    ToolFinished {
220        turn: TurnId,
221        call_id: ToolCallId,
222        outcome: ToolOutcome,
223    },
224    /// A gated tool is awaiting the user's inline approval (interactive
225    /// `ask` mode / Auto-mode escalation). The reducer enqueues a modal; the
226    /// answer flows back as `Cmd::ResolveApproval`. The tool task is parked
227    /// until then, so the turn naturally pauses (its outcome slot stays
228    /// `None`).
229    ApprovalRequested {
230        turn: TurnId,
231        call_id: ToolCallId,
232        tool: String,
233        risk: String,
234        kind: ApprovalKind,
235        prompt: String,
236        allowlist_scope: String,
237    },
238    /// The `ask_user_question` tool is asking the user a batch of questions. The
239    /// reducer stores a `PendingQuestionSet` and renders a selectable modal; the
240    /// answer flows back as `Cmd::ResolveQuestion`. The tool task is parked until
241    /// then, so the turn naturally pauses (its outcome slot stays `None`).
242    QuestionAsked {
243        turn: TurnId,
244        call_id: ToolCallId,
245        questions: Vec<Question>,
246    },
247    /// The `TaskBroker` published a full checklist snapshot after a task tool
248    /// (or `/tasks` edit) mutated the store. Fire-and-forget — the broker
249    /// already holds the new truth; the reducer replaces `conversation.tasks`
250    /// and diffs old vs new for `task_completed` hook dispatch. Snapshot (not
251    /// a diff) so this arm is a plain replace and can never desync. Not
252    /// turn-scoped: `/tasks` edits arrive outside any turn, and gating would
253    /// only let the render copy drift from the broker's truth.
254    TasksUpdated {
255        store: crate::domain::tasks::TaskStore,
256    },
257    /// A one-line checklist notice for the model's next request (user
258    /// `/todos` edit, vetoed completion). Buffered on
259    /// `state.pending_task_notices`; the reducer never turn-gates it.
260    TaskNotice {
261        text: String,
262    },
263
264    // ── MCP (from effect::mcp) ──────────────────────────────────────
265    /// `initialize` succeeded; server is ready to dispatch tools.
266    McpServerReady {
267        name: String,
268        tools: Vec<McpToolSpec>,
269    },
270    /// Server startup failed OR the child exited with non-zero.
271    McpServerErrored {
272        name: String,
273        reason: String,
274    },
275    McpServerStopped {
276        name: String,
277    },
278
279    /// Context strings returned by `before_tool_use` plugin hooks
280    /// (`additionalContext`). The reducer buffers them (capped) and the next
281    /// dispatched model request carries them in the instructions channel,
282    /// consumed exactly once. Turn-scoped so a stale hook's context can't
283    /// leak into a later run.
284    HookContext {
285        turn: TurnId,
286        texts: Vec<String>,
287    },
288
289    // ── Persistence (from effect::persistence) ──────────────────────
290    /// `MERMAID.md` loaded / changed / removed since last check.
291    InstructionsChanged(Option<LoadedInstructions>),
292    /// Memory files loaded / changed / removed since last check.
293    MemoryChanged(Option<crate::app::memory::LoadedMemory>),
294    /// `save_conversation` finished.
295    SessionSaved,
296    /// `/load <id>` — a saved conversation has been read off disk.
297    ConversationLoaded(crate::session::ConversationHistory),
298    /// Response to `Cmd::ListConversations`. Populates the `/load`
299    /// picker's candidate list.
300    ConversationsListed(Vec<ConversationSummary>),
301    /// Discovery for the `/model` picker finished. Carries every model the
302    /// user can switch to, already grouped and sorted by the effect layer.
303    AvailableModelsListed(Vec<crate::domain::state::ModelChoice>),
304    /// Response to `Cmd::ListProjectFiles`: relative project paths for the
305    /// @-mention picker (gitignore-aware walk, capped, sorted; directories
306    /// carry a trailing `/`).
307    ProjectFilesListed(Vec<String>),
308    /// Response to `Cmd::EnsureScratchpad`: the per-session scratch directory
309    /// exists on disk at `path`. Carries the conversation id it was minted
310    /// for so the reducer can drop a stale ready that raced a `/clear` or
311    /// `/load` — it stamps `Session::scratchpad` only when the id still
312    /// matches the live conversation.
313    ScratchpadReady {
314        session_id: String,
315        path: PathBuf,
316    },
317    /// Response to `/tasks`.
318    RuntimeTasksListed(Vec<TaskRecord>),
319    /// Response to `/task <id>`.
320    RuntimeTaskLoaded {
321        task: Option<TaskRecord>,
322        events: Vec<TaskTimelineEvent>,
323    },
324    /// Response to `/processes`.
325    RuntimeProcessesListed(Vec<ProcessRecord>),
326    /// Generic daemon/runtime text response.
327    RuntimeText(String),
328    RuntimeApprovalsListed(Vec<ApprovalRecord>),
329    RuntimeCheckpointsListed(Vec<CheckpointRecord>),
330    /// Reply to `Cmd::ListForkCheckpoints`: file checkpoints anchored past a
331    /// rewind's fork point (oldest first). The reducer emits a system notice
332    /// naming the oldest so the user can `/restore` files the discarded
333    /// timeline changed; empty means no notice.
334    ForkCheckpointsFound(Vec<CheckpointRecord>),
335    RuntimePluginsListed(Vec<PluginInstallRecord>),
336
337    // ── Misc model operations ───────────────────────────────────────
338    /// `/model <name>` finished pulling (Ollama only).
339    ModelPullFinished {
340        model: String,
341    },
342    /// Streaming stdout line from an `ollama pull` subprocess.
343    /// Reducer forwards to the status line for the user to watch.
344    ModelPullProgress(String),
345
346    // ── Housekeeping ────────────────────────────────────────────────
347    /// 1/60s timer tick. Used for spinner animation + elapsed-time
348    /// display. Reducer only advances derived fields.
349    Tick,
350    /// Terminal was resized. Reducer normally no-ops; render consumes.
351    Resize {
352        width: u16,
353        height: u16,
354    },
355
356    // ── Status feedback from async effects ─────────────────────────
357    /// Generic user-visible feedback from an async effect handler, routed into
358    /// the chat transcript. Lets effects surface a result (clipboard read,
359    /// config saved, plugin install, …) without a bespoke Msg per effect.
360    TransientStatus {
361        text: String,
362    },
363
364    /// Ephemeral confirmation of a manual action (clipboard copy), shown just
365    /// above the input for [`crate::domain::TOAST_TTL`] and then gone. The
366    /// sibling of `TransientStatus` for feedback that must NOT become a
367    /// permanent transcript row.
368    Toast {
369        text: String,
370    },
371
372    /// The `$EDITOR` compose round-trip finished (Ctrl+O / `/editor`). The
373    /// run loop suspends the TUI, launches the editor, and pushes this with
374    /// the edited draft — so the recording captures the RESULT and `--replay`
375    /// never launches an editor. `Some(text)` replaces the input buffer
376    /// (empty = deliberate clear); `None` = no-op (defensive).
377    EditorReturned {
378        text: Option<String>,
379    },
380
381    // ── Background subagents (Ctrl+B detach) ───────────────────────
382    /// A subagent was detached from its turn via Ctrl+B and keeps running
383    /// in its own task. Adds a row to the live agent panel registry.
384    BackgroundAgentStarted {
385        agent_id: String,
386        description: String,
387    },
388    /// Throttled live activity from a detached subagent (same discipline as
389    /// `ProgressEvent::Subagent*` — never per stream chunk).
390    BackgroundAgentProgress {
391        agent_id: String,
392        activity: String,
393        tokens: usize,
394    },
395    /// A detached subagent finished. Removes its panel row, folds the child's
396    /// spend into the session totals, and delivers the report to the model
397    /// via the queued-message path.
398    BackgroundAgentFinished {
399        agent_id: String,
400        description: String,
401        report: String,
402        success: bool,
403        /// The drive ended because its cancel token fired (`/agents kill`,
404        /// the `agent` tool's kill action). A cancelled child gets a system
405        /// note but NO queued report — the killer already knows.
406        #[serde(default)]
407        cancelled: bool,
408        /// Provider-reported usage for the child's whole drive (None when the
409        /// provider reported nothing — the display falls back to `tokens`).
410        usage: Option<TokenUsage>,
411        /// Display token count (usage total, or the live estimate).
412        tokens: usize,
413        duration_secs: u64,
414    },
415
416    // ── Mouse (F13) ─────────────────────────────────────────────────
417    /// Mouse-wheel scroll in the chat pane. Positive delta = scroll
418    /// toward older messages (up), negative = toward newer (down). The
419    /// reducer accumulates into `ui.mouse_scroll_accum`; the render layer
420    /// diffs it and applies the delta to the ChatWidget's scroll offset.
421    MouseScroll {
422        delta: i16,
423    },
424    /// The terminal window gained (`true`) or lost (`false`) focus, from
425    /// terminal focus reporting. The reducer records it in
426    /// `ui.terminal_unfocused` to gate the attention bell.
427    FocusChanged(bool),
428    /// Ctrl+Click on an image thumbnail in the chat pane. The
429    /// coordinates are absolute screen row/col; the render cache's
430    /// `ChatState::find_image_at_screen_pos` maps them to a
431    /// `(message_index, image_index)` pair. The main loop handles the
432    /// lookup before forwarding this message to the reducer, so by
433    /// the time the reducer sees it, the target has already been
434    /// resolved into a base64 payload and this Msg carries the
435    /// already-decoded image. The reducer just emits
436    /// `Cmd::WriteImageToTemp` + `Cmd::OpenInSystem`.
437    OpenImageAt {
438        message_index: usize,
439        image_index: usize,
440        /// The clicked image's stable global `[Image #N]` number, when known.
441        /// Preferred over the positional pair: the display transcript can be
442        /// stitched (continuation merge, hidden nudges), so display indices
443        /// need not match committed history. `default` so pre-stitch
444        /// recordings replay.
445        #[serde(default)]
446        image_number: Option<u64>,
447    },
448    /// Copy the current chat text selection to the system clipboard. The main
449    /// loop reads the selected text from the render layer (`rstate.chat`) and
450    /// emits this, so the side effect flows through `update()` — and is recorded
451    /// for replay — instead of being dispatched out-of-band (#18).
452    CopySelection(String),
453}
454
455/// Bare key event — deliberately smaller than crossterm's `KeyEvent`
456/// so the reducer doesn't depend on crossterm. The app event source
457/// does the conversion.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
459pub struct Key {
460    pub code: KeyCode,
461    pub modifiers: KeyMods,
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
465pub enum KeyCode {
466    Char(char),
467    Enter,
468    Escape,
469    Backspace,
470    Delete,
471    Tab,
472    BackTab,
473    Left,
474    Right,
475    Up,
476    Down,
477    Home,
478    End,
479    PageUp,
480    PageDown,
481    F(u8),
482    /// Anything we don't care about (media keys, etc.).
483    Unknown,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
487pub struct KeyMods {
488    pub ctrl: bool,
489    pub alt: bool,
490    pub shift: bool,
491}
492
493impl KeyMods {
494    pub const NONE: Self = Self {
495        ctrl: false,
496        alt: false,
497        shift: false,
498    };
499
500    pub const fn ctrl() -> Self {
501        Self {
502            ctrl: true,
503            ..Self::NONE
504        }
505    }
506
507    pub const fn alt() -> Self {
508        Self {
509            alt: true,
510            ..Self::NONE
511        }
512    }
513
514    pub fn is_empty(self) -> bool {
515        !self.ctrl && !self.alt && !self.shift
516    }
517}
518
519/// Terminal paste payload. Always text: crossterm bracketed paste (and the
520/// Windows key-burst coalescer) only ever deliver text. Clipboard reads via
521/// Ctrl+V — which can be images — arrive separately as [`Msg::ClipboardRead`]
522/// so the paste-race guard can tell the two apart.
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub enum Paste {
525    Text(String),
526}
527
528/// Result of a `Cmd::ReadClipboard` (Ctrl+V), delivered asynchronously by the
529/// effect runner. Distinct from [`Paste`] (terminal bracketed paste) so the
530/// reducer can decrement `clipboard_reads_pending` on exactly these — and only
531/// these — messages, including the empty/error outcomes, which must still
532/// release a submit that was held waiting on the read.
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub enum ClipboardRead {
535    /// A raster image. Bytes serialize as base64 so a recorded session replays
536    /// pasted images bit-exactly without a numbers-array blowup in the JSONL.
537    Image {
538        #[serde(with = "crate::utils::serde_base64")]
539        bytes: Vec<u8>,
540        format: String,
541    },
542    /// Plain text on the clipboard.
543    Text(String),
544    /// The clipboard held nothing readable.
545    Empty,
546    /// The read failed (helper missing, timed out, etc.); the string is a
547    /// user-facing reason.
548    Error(String),
549}
550
551/// `/context` subcommands. No-arg shows the window; the rest tune the Ollama
552/// context window (per-model, persisted), mirroring `/reasoning`.
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub enum ContextCmd {
555    /// Show the current window + usage (no arg).
556    Show,
557    /// `/context <n>` — set a per-model `num_ctx` override.
558    Set(u32),
559    /// `/context auto` — clear the override, return to auto-fit.
560    Auto,
561    /// `/context max` — use the model's full advertised window.
562    Max,
563    /// `/context offload on|off` — toggle Ollama RAM offload.
564    Offload(bool),
565}
566
567/// Slash commands — a typed surface over what the user typed as
568/// `/<name> [args]`. Parsed in `app::event_source` against the single
569/// `COMMAND_REGISTRY`; unknown commands produce `SlashCmd::Unknown`
570/// so the reducer can issue a "no such command" status line.
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572pub enum SlashCmd {
573    /// No arg → show current; `Some` → switch (and pull if needed).
574    Model(Option<String>),
575    Reasoning(Option<ReasoningLevel>),
576    VisibleReasoning(Option<String>),
577    /// No arg → show current safety mode; `Some` → switch it for this
578    /// session, `plan` included (`Shift+Tab` cycles the same field).
579    /// Session-scoped.
580    Safety(Option<SafetyMode>),
581    /// Plan mode: no arg / `on` → enter; `off` → leave; `show` → print the
582    /// plan-file path; `config` → open the settings picker. `plan` is a
583    /// `SafetyMode`, so `Shift+Tab` and `/safety plan` reach the same state.
584    /// Session-scoped.
585    Plan(Option<String>),
586    /// Open the settings picker (currently the plan-mode section; more
587    /// sections join it as they exist).
588    Config,
589    Clear,
590    Save(Option<String>),
591    Load(Option<String>),
592    List,
593    Usage,
594    /// The task checklist: no arg → show; `add <subject>` / `rm <id>` /
595    /// `done <id>` / `clear` edit it (routed through the TaskBroker).
596    Todos(Option<String>),
597    /// Show the session scratch directory and a bounded listing of its
598    /// contents (via `Cmd::ListScratchpad`).
599    Scratchpad,
600    Context(ContextCmd),
601    Compact(Option<String>),
602    /// List saved durable memories.
603    Memory,
604    /// Save free-text as a private memory.
605    Remember(Option<String>),
606    /// Delete a memory by name/id.
607    Forget(Option<String>),
608    /// Prune duplicate/obsolete memories via a one-shot model pass.
609    ConsolidateMemory,
610    Doctor,
611    Tasks,
612    Task(Option<String>),
613    Pause(Option<String>),
614    Resume(Option<String>),
615    Cancel(Option<String>),
616    Handoff(Option<String>),
617    Report(Option<String>),
618    Processes,
619    /// No arg → list background agents; `Some("kill <id>"|"kill all")` →
620    /// cancel them. Tail parsed in the reducer arm.
621    Agents(Option<String>),
622    Logs(Option<String>),
623    Stop(Option<String>),
624    Restart(Option<String>),
625    Open(Option<String>),
626    Ports,
627    Approvals,
628    Approve(Option<String>),
629    Deny(Option<String>),
630    Checkpoint(Option<String>),
631    Checkpoints,
632    Restore(Option<String>),
633    ModelInfo(Option<String>),
634    Plugins,
635    CloudSetup,
636    /// No arg → show current theme; `Some("dark"|"light")` → switch and
637    /// persist. Anything else → usage.
638    Theme(Option<String>),
639    /// Compose the input draft in `$VISUAL`/`$EDITOR` (also Ctrl+O).
640    Editor,
641    Help,
642    Quit,
643    /// User typed something that isn't in the registry; carries the
644    /// raw name for the error message.
645    Unknown(String),
646}
647
648impl Msg {
649    /// Extract the `TurnId` for effect-result variants. Returns `None`
650    /// for variants that aren't turn-scoped (user intent,
651    /// housekeeping, MCP lifecycle). The reducer uses this to
652    /// short-circuit stale events.
653    pub fn turn_id(&self) -> Option<TurnId> {
654        match self {
655            Msg::StreamText { turn, .. }
656            | Msg::StreamReasoning { turn, .. }
657            | Msg::StreamToolCall { turn, .. }
658            | Msg::ContextUsageEstimated { turn, .. }
659            | Msg::CompactionFinished { turn, .. }
660            | Msg::CompactionFailed { turn, .. }
661            | Msg::StreamDone { turn, .. }
662            | Msg::UpstreamError { turn, .. }
663            | Msg::ToolStarted { turn, .. }
664            | Msg::ToolProgress { turn, .. }
665            | Msg::ToolFinished { turn, .. }
666            | Msg::ApprovalRequested { turn, .. }
667            | Msg::QuestionAsked { turn, .. }
668            | Msg::HookContext { turn, .. } => Some(*turn),
669            Msg::TurnCancelled(turn) => Some(*turn),
670            _ => None,
671        }
672    }
673
674    /// Classification for telemetry / replay tooling. Cheaper than a
675    /// full `Debug` string and stable across refactors.
676    pub fn kind(&self) -> MsgKind {
677        match self {
678            Msg::Key(_) => MsgKind::Key,
679            Msg::Paste(_) => MsgKind::Paste,
680            Msg::ClipboardRead(_) => MsgKind::ClipboardRead,
681            Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
682            Msg::Slash(_) => MsgKind::Slash,
683            Msg::CancelTurn => MsgKind::CancelTurn,
684            Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
685            Msg::Quit => MsgKind::Quit,
686            Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
687            Msg::StreamText { .. } => MsgKind::StreamText,
688            Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
689            Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
690            Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
691            Msg::ProviderContextResolved { .. } => MsgKind::ProviderContextResolved,
692            Msg::OllamaPlacementResolved { .. } => MsgKind::OllamaPlacementResolved,
693            Msg::ProviderVisionResolved { .. } => MsgKind::ProviderVisionResolved,
694            Msg::BuiltinToolSchemaTokens(_) => MsgKind::BuiltinToolSchemaTokens,
695            Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
696            Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
697            Msg::StreamDone { .. } => MsgKind::StreamDone,
698            Msg::UpstreamError { .. } => MsgKind::UpstreamError,
699            Msg::ToolStarted { .. } => MsgKind::ToolStarted,
700            Msg::ToolProgress { .. } => MsgKind::ToolProgress,
701            Msg::ToolFinished { .. } => MsgKind::ToolFinished,
702            Msg::ApprovalRequested { .. } => MsgKind::ApprovalRequested,
703            Msg::QuestionAsked { .. } => MsgKind::QuestionAsked,
704            Msg::TasksUpdated { .. } => MsgKind::TasksUpdated,
705            Msg::TaskNotice { .. } => MsgKind::TaskNotice,
706            Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
707            Msg::McpServerReady { .. }
708            | Msg::McpServerErrored { .. }
709            | Msg::McpServerStopped { .. } => MsgKind::Mcp,
710            Msg::HookContext { .. } => MsgKind::HookContext,
711            Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
712            Msg::MemoryChanged(_) => MsgKind::MemoryChanged,
713            Msg::SessionSaved => MsgKind::SessionSaved,
714            Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
715            Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
716            Msg::AvailableModelsListed(_) => MsgKind::AvailableModelsListed,
717            Msg::ProjectFilesListed(_) => MsgKind::ProjectFilesListed,
718            Msg::ScratchpadReady { .. } => MsgKind::ScratchpadReady,
719            Msg::RuntimeTasksListed(_)
720            | Msg::RuntimeTaskLoaded { .. }
721            | Msg::RuntimeProcessesListed(_)
722            | Msg::RuntimeText(_)
723            | Msg::RuntimeApprovalsListed(_)
724            | Msg::RuntimeCheckpointsListed(_)
725            | Msg::ForkCheckpointsFound(_)
726            | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
727            Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
728            Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
729            Msg::Tick => MsgKind::Tick,
730            Msg::Resize { .. } => MsgKind::Resize,
731            Msg::MouseScroll { .. } => MsgKind::MouseScroll,
732            Msg::FocusChanged(_) => MsgKind::FocusChanged,
733            Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
734            Msg::TransientStatus { .. } => MsgKind::TransientStatus,
735            Msg::Toast { .. } => MsgKind::Toast,
736            Msg::EditorReturned { .. } => MsgKind::EditorReturned,
737            Msg::BackgroundAgentStarted { .. }
738            | Msg::BackgroundAgentProgress { .. }
739            | Msg::BackgroundAgentFinished { .. } => MsgKind::BackgroundAgent,
740            Msg::CopySelection(_) => MsgKind::CopySelection,
741        }
742    }
743}
744
745/// Compact kind tag for tracing / replay indexing.
746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub enum MsgKind {
748    Key,
749    Paste,
750    ClipboardRead,
751    SubmitPrompt,
752    Slash,
753    CancelTurn,
754    Confirm,
755    Quit,
756    RuntimeSignal,
757    StreamText,
758    StreamReasoning,
759    StreamToolCall,
760    ContextUsageEstimated,
761    ProviderContextResolved,
762    OllamaPlacementResolved,
763    ProviderVisionResolved,
764    BuiltinToolSchemaTokens,
765    CompactionFinished,
766    CompactionFailed,
767    StreamDone,
768    UpstreamError,
769    ToolStarted,
770    ToolProgress,
771    ToolFinished,
772    ApprovalRequested,
773    QuestionAsked,
774    TasksUpdated,
775    TaskNotice,
776    TurnCancelled,
777    Mcp,
778    InstructionsChanged,
779    HookContext,
780    MemoryChanged,
781    SessionSaved,
782    ConversationLoaded,
783    ConversationsListed,
784    AvailableModelsListed,
785    ProjectFilesListed,
786    ScratchpadReady,
787    RuntimeStore,
788    ModelPullFinished,
789    ModelPullProgress,
790    Tick,
791    Resize,
792    MouseScroll,
793    FocusChanged,
794    BackgroundAgent,
795    OpenImageAt,
796    TransientStatus,
797    Toast,
798    EditorReturned,
799    CopySelection,
800}
801
802/// Helper for `app::event_source` — pass through the MCP config that
803/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
804/// Not a `Msg` because it's startup-only.
805#[derive(Debug, Clone)]
806pub struct StartupConfig {
807    pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
808    pub cwd: PathBuf,
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn turn_id_extracted_from_stream_messages() {
817        let m = Msg::StreamText {
818            turn: TurnId(7),
819            chunk: "hi".to_string(),
820        };
821        assert_eq!(m.turn_id(), Some(TurnId(7)));
822    }
823
824    #[test]
825    fn turn_id_none_for_user_intent() {
826        let m = Msg::CancelTurn;
827        assert_eq!(m.turn_id(), None);
828        let m = Msg::Quit;
829        assert_eq!(m.turn_id(), None);
830        let m = Msg::Tick;
831        assert_eq!(m.turn_id(), None);
832    }
833
834    #[test]
835    fn turn_id_none_for_mcp_lifecycle() {
836        let m = Msg::McpServerReady {
837            name: "s".to_string(),
838            tools: vec![],
839        };
840        assert_eq!(m.turn_id(), None);
841    }
842
843    #[test]
844    fn key_mods_builder_defaults_match_const() {
845        assert_eq!(KeyMods::default(), KeyMods::NONE);
846        assert!(KeyMods::ctrl().ctrl);
847        assert!(!KeyMods::ctrl().alt);
848        assert!(!KeyMods::ctrl().shift);
849    }
850
851    #[test]
852    fn kind_stable_across_variants() {
853        assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
854        assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
855        assert_eq!(
856            Msg::StreamText {
857                turn: TurnId(1),
858                chunk: String::new()
859            }
860            .kind(),
861            MsgKind::StreamText
862        );
863    }
864
865    #[test]
866    fn slash_cmd_carries_none_for_no_arg() {
867        let c = SlashCmd::Model(None);
868        assert_eq!(c, SlashCmd::Model(None));
869        assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
870    }
871}