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    /// Response to `Cmd::ListProjectFiles`: relative project paths for the
302    /// @-mention picker (gitignore-aware walk, capped, sorted; directories
303    /// carry a trailing `/`).
304    ProjectFilesListed(Vec<String>),
305    /// Response to `Cmd::EnsureScratchpad`: the per-session scratch directory
306    /// exists on disk at `path`. Carries the conversation id it was minted
307    /// for so the reducer can drop a stale ready that raced a `/clear` or
308    /// `/load` — it stamps `Session::scratchpad` only when the id still
309    /// matches the live conversation.
310    ScratchpadReady {
311        session_id: String,
312        path: PathBuf,
313    },
314    /// Response to `/tasks`.
315    RuntimeTasksListed(Vec<TaskRecord>),
316    /// Response to `/task <id>`.
317    RuntimeTaskLoaded {
318        task: Option<TaskRecord>,
319        events: Vec<TaskTimelineEvent>,
320    },
321    /// Response to `/processes`.
322    RuntimeProcessesListed(Vec<ProcessRecord>),
323    /// Generic daemon/runtime text response.
324    RuntimeText(String),
325    RuntimeApprovalsListed(Vec<ApprovalRecord>),
326    RuntimeCheckpointsListed(Vec<CheckpointRecord>),
327    /// Reply to `Cmd::ListForkCheckpoints`: file checkpoints anchored past a
328    /// rewind's fork point (oldest first). The reducer emits a system notice
329    /// naming the oldest so the user can `/restore` files the discarded
330    /// timeline changed; empty means no notice.
331    ForkCheckpointsFound(Vec<CheckpointRecord>),
332    RuntimePluginsListed(Vec<PluginInstallRecord>),
333
334    // ── Misc model operations ───────────────────────────────────────
335    /// `/model <name>` finished pulling (Ollama only).
336    ModelPullFinished {
337        model: String,
338    },
339    /// Streaming stdout line from an `ollama pull` subprocess.
340    /// Reducer forwards to the status line for the user to watch.
341    ModelPullProgress(String),
342
343    // ── Housekeeping ────────────────────────────────────────────────
344    /// 1/60s timer tick. Used for spinner animation + elapsed-time
345    /// display. Reducer only advances derived fields.
346    Tick,
347    /// Terminal was resized. Reducer normally no-ops; render consumes.
348    Resize {
349        width: u16,
350        height: u16,
351    },
352
353    // ── Status feedback from async effects ─────────────────────────
354    /// Generic user-visible feedback from an async effect handler, routed into
355    /// the chat transcript. Lets effects surface a result (clipboard read,
356    /// config saved, plugin install, …) without a bespoke Msg per effect.
357    TransientStatus {
358        text: String,
359    },
360
361    /// The `$EDITOR` compose round-trip finished (Ctrl+O / `/editor`). The
362    /// run loop suspends the TUI, launches the editor, and pushes this with
363    /// the edited draft — so the recording captures the RESULT and `--replay`
364    /// never launches an editor. `Some(text)` replaces the input buffer
365    /// (empty = deliberate clear); `None` = no-op (defensive).
366    EditorReturned {
367        text: Option<String>,
368    },
369
370    // ── Background subagents (Ctrl+B detach) ───────────────────────
371    /// A subagent was detached from its turn via Ctrl+B and keeps running
372    /// in its own task. Adds a row to the live agent panel registry.
373    BackgroundAgentStarted {
374        agent_id: String,
375        description: String,
376    },
377    /// Throttled live activity from a detached subagent (same discipline as
378    /// `ProgressEvent::Subagent*` — never per stream chunk).
379    BackgroundAgentProgress {
380        agent_id: String,
381        activity: String,
382        tokens: usize,
383    },
384    /// A detached subagent finished. Removes its panel row, folds the child's
385    /// spend into the session totals, and delivers the report to the model
386    /// via the queued-message path.
387    BackgroundAgentFinished {
388        agent_id: String,
389        description: String,
390        report: String,
391        success: bool,
392        /// The drive ended because its cancel token fired (`/agents kill`,
393        /// the `agent` tool's kill action). A cancelled child gets a system
394        /// note but NO queued report — the killer already knows.
395        #[serde(default)]
396        cancelled: bool,
397        /// Provider-reported usage for the child's whole drive (None when the
398        /// provider reported nothing — the display falls back to `tokens`).
399        usage: Option<TokenUsage>,
400        /// Display token count (usage total, or the live estimate).
401        tokens: usize,
402        duration_secs: u64,
403    },
404
405    // ── Mouse (F13) ─────────────────────────────────────────────────
406    /// Mouse-wheel scroll in the chat pane. Positive delta = scroll
407    /// toward older messages (up), negative = toward newer (down). The
408    /// reducer accumulates into `ui.mouse_scroll_accum`; the render layer
409    /// diffs it and applies the delta to the ChatWidget's scroll offset.
410    MouseScroll {
411        delta: i16,
412    },
413    /// The terminal window gained (`true`) or lost (`false`) focus, from
414    /// terminal focus reporting. The reducer records it in
415    /// `ui.terminal_unfocused` to gate the attention bell.
416    FocusChanged(bool),
417    /// Ctrl+Click on an image thumbnail in the chat pane. The
418    /// coordinates are absolute screen row/col; the render cache's
419    /// `ChatState::find_image_at_screen_pos` maps them to a
420    /// `(message_index, image_index)` pair. The main loop handles the
421    /// lookup before forwarding this message to the reducer, so by
422    /// the time the reducer sees it, the target has already been
423    /// resolved into a base64 payload and this Msg carries the
424    /// already-decoded image. The reducer just emits
425    /// `Cmd::WriteImageToTemp` + `Cmd::OpenInSystem`.
426    OpenImageAt {
427        message_index: usize,
428        image_index: usize,
429        /// The clicked image's stable global `[Image #N]` number, when known.
430        /// Preferred over the positional pair: the display transcript can be
431        /// stitched (continuation merge, hidden nudges), so display indices
432        /// need not match committed history. `default` so pre-stitch
433        /// recordings replay.
434        #[serde(default)]
435        image_number: Option<u64>,
436    },
437    /// Copy the current chat text selection to the system clipboard. The main
438    /// loop reads the selected text from the render layer (`rstate.chat`) and
439    /// emits this, so the side effect flows through `update()` — and is recorded
440    /// for replay — instead of being dispatched out-of-band (#18).
441    CopySelection(String),
442}
443
444/// Bare key event — deliberately smaller than crossterm's `KeyEvent`
445/// so the reducer doesn't depend on crossterm. The app event source
446/// does the conversion.
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448pub struct Key {
449    pub code: KeyCode,
450    pub modifiers: KeyMods,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454pub enum KeyCode {
455    Char(char),
456    Enter,
457    Escape,
458    Backspace,
459    Delete,
460    Tab,
461    BackTab,
462    Left,
463    Right,
464    Up,
465    Down,
466    Home,
467    End,
468    PageUp,
469    PageDown,
470    F(u8),
471    /// Anything we don't care about (media keys, etc.).
472    Unknown,
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
476pub struct KeyMods {
477    pub ctrl: bool,
478    pub alt: bool,
479    pub shift: bool,
480}
481
482impl KeyMods {
483    pub const NONE: Self = Self {
484        ctrl: false,
485        alt: false,
486        shift: false,
487    };
488
489    pub const fn ctrl() -> Self {
490        Self {
491            ctrl: true,
492            ..Self::NONE
493        }
494    }
495
496    pub const fn alt() -> Self {
497        Self {
498            alt: true,
499            ..Self::NONE
500        }
501    }
502
503    pub fn is_empty(self) -> bool {
504        !self.ctrl && !self.alt && !self.shift
505    }
506}
507
508/// Terminal paste payload. Always text: crossterm bracketed paste (and the
509/// Windows key-burst coalescer) only ever deliver text. Clipboard reads via
510/// Ctrl+V — which can be images — arrive separately as [`Msg::ClipboardRead`]
511/// so the paste-race guard can tell the two apart.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub enum Paste {
514    Text(String),
515}
516
517/// Result of a `Cmd::ReadClipboard` (Ctrl+V), delivered asynchronously by the
518/// effect runner. Distinct from [`Paste`] (terminal bracketed paste) so the
519/// reducer can decrement `clipboard_reads_pending` on exactly these — and only
520/// these — messages, including the empty/error outcomes, which must still
521/// release a submit that was held waiting on the read.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub enum ClipboardRead {
524    /// A raster image. Bytes serialize as base64 so a recorded session replays
525    /// pasted images bit-exactly without a numbers-array blowup in the JSONL.
526    Image {
527        #[serde(with = "crate::utils::serde_base64")]
528        bytes: Vec<u8>,
529        format: String,
530    },
531    /// Plain text on the clipboard.
532    Text(String),
533    /// The clipboard held nothing readable.
534    Empty,
535    /// The read failed (helper missing, timed out, etc.); the string is a
536    /// user-facing reason.
537    Error(String),
538}
539
540/// `/context` subcommands. No-arg shows the window; the rest tune the Ollama
541/// context window (per-model, persisted), mirroring `/reasoning`.
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub enum ContextCmd {
544    /// Show the current window + usage (no arg).
545    Show,
546    /// `/context <n>` — set a per-model `num_ctx` override.
547    Set(u32),
548    /// `/context auto` — clear the override, return to auto-fit.
549    Auto,
550    /// `/context max` — use the model's full advertised window.
551    Max,
552    /// `/context offload on|off` — toggle Ollama RAM offload.
553    Offload(bool),
554}
555
556/// Slash commands — a typed surface over what the user typed as
557/// `/<name> [args]`. Parsed in `app::event_source` against the single
558/// `COMMAND_REGISTRY`; unknown commands produce `SlashCmd::Unknown`
559/// so the reducer can issue a "no such command" status line.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub enum SlashCmd {
562    /// No arg → show current; `Some` → switch (and pull if needed).
563    Model(Option<String>),
564    Reasoning(Option<ReasoningLevel>),
565    VisibleReasoning(Option<String>),
566    /// No arg → show current safety mode; `Some` → switch it for this
567    /// session (`Shift+Tab` cycles the same field). Session-scoped.
568    Safety(Option<SafetyMode>),
569    /// Plan mode: no arg / `on` → enter; `off` → leave; `show` → print the
570    /// plan-file path; `config` → open the settings picker. `Alt+P` toggles
571    /// the same state. Session-scoped.
572    Plan(Option<String>),
573    /// Open the settings picker (currently the plan-mode section; more
574    /// sections join it as they exist).
575    Config,
576    Clear,
577    Save(Option<String>),
578    Load(Option<String>),
579    List,
580    Usage,
581    /// The task checklist: no arg → show; `add <subject>` / `rm <id>` /
582    /// `done <id>` / `clear` edit it (routed through the TaskBroker).
583    Todos(Option<String>),
584    /// Show the session scratch directory and a bounded listing of its
585    /// contents (via `Cmd::ListScratchpad`).
586    Scratchpad,
587    Context(ContextCmd),
588    Compact(Option<String>),
589    /// List saved durable memories.
590    Memory,
591    /// Save free-text as a private memory.
592    Remember(Option<String>),
593    /// Delete a memory by name/id.
594    Forget(Option<String>),
595    /// Prune duplicate/obsolete memories via a one-shot model pass.
596    ConsolidateMemory,
597    Doctor,
598    Tasks,
599    Task(Option<String>),
600    Pause(Option<String>),
601    Resume(Option<String>),
602    Cancel(Option<String>),
603    Handoff(Option<String>),
604    Report(Option<String>),
605    Processes,
606    /// No arg → list background agents; `Some("kill <id>"|"kill all")` →
607    /// cancel them. Tail parsed in the reducer arm.
608    Agents(Option<String>),
609    Logs(Option<String>),
610    Stop(Option<String>),
611    Restart(Option<String>),
612    Open(Option<String>),
613    Ports,
614    Approvals,
615    Approve(Option<String>),
616    Deny(Option<String>),
617    Checkpoint(Option<String>),
618    Checkpoints,
619    Restore(Option<String>),
620    ModelInfo(Option<String>),
621    Plugins,
622    CloudSetup,
623    /// No arg → show current theme; `Some("dark"|"light")` → switch and
624    /// persist. Anything else → usage.
625    Theme(Option<String>),
626    /// Compose the input draft in `$VISUAL`/`$EDITOR` (also Ctrl+O).
627    Editor,
628    Help,
629    Quit,
630    /// User typed something that isn't in the registry; carries the
631    /// raw name for the error message.
632    Unknown(String),
633}
634
635impl Msg {
636    /// Extract the `TurnId` for effect-result variants. Returns `None`
637    /// for variants that aren't turn-scoped (user intent,
638    /// housekeeping, MCP lifecycle). The reducer uses this to
639    /// short-circuit stale events.
640    pub fn turn_id(&self) -> Option<TurnId> {
641        match self {
642            Msg::StreamText { turn, .. }
643            | Msg::StreamReasoning { turn, .. }
644            | Msg::StreamToolCall { turn, .. }
645            | Msg::ContextUsageEstimated { turn, .. }
646            | Msg::CompactionFinished { turn, .. }
647            | Msg::CompactionFailed { turn, .. }
648            | Msg::StreamDone { turn, .. }
649            | Msg::UpstreamError { turn, .. }
650            | Msg::ToolStarted { turn, .. }
651            | Msg::ToolProgress { turn, .. }
652            | Msg::ToolFinished { turn, .. }
653            | Msg::ApprovalRequested { turn, .. }
654            | Msg::QuestionAsked { turn, .. }
655            | Msg::HookContext { turn, .. } => Some(*turn),
656            Msg::TurnCancelled(turn) => Some(*turn),
657            _ => None,
658        }
659    }
660
661    /// Classification for telemetry / replay tooling. Cheaper than a
662    /// full `Debug` string and stable across refactors.
663    pub fn kind(&self) -> MsgKind {
664        match self {
665            Msg::Key(_) => MsgKind::Key,
666            Msg::Paste(_) => MsgKind::Paste,
667            Msg::ClipboardRead(_) => MsgKind::ClipboardRead,
668            Msg::SubmitPrompt { .. } => MsgKind::SubmitPrompt,
669            Msg::Slash(_) => MsgKind::Slash,
670            Msg::CancelTurn => MsgKind::CancelTurn,
671            Msg::ConfirmAccepted | Msg::ConfirmDeclined => MsgKind::Confirm,
672            Msg::Quit => MsgKind::Quit,
673            Msg::RuntimeSignal(_) => MsgKind::RuntimeSignal,
674            Msg::StreamText { .. } => MsgKind::StreamText,
675            Msg::StreamReasoning { .. } => MsgKind::StreamReasoning,
676            Msg::StreamToolCall { .. } => MsgKind::StreamToolCall,
677            Msg::ContextUsageEstimated { .. } => MsgKind::ContextUsageEstimated,
678            Msg::ProviderContextResolved { .. } => MsgKind::ProviderContextResolved,
679            Msg::OllamaPlacementResolved { .. } => MsgKind::OllamaPlacementResolved,
680            Msg::ProviderVisionResolved { .. } => MsgKind::ProviderVisionResolved,
681            Msg::BuiltinToolSchemaTokens(_) => MsgKind::BuiltinToolSchemaTokens,
682            Msg::CompactionFinished { .. } => MsgKind::CompactionFinished,
683            Msg::CompactionFailed { .. } => MsgKind::CompactionFailed,
684            Msg::StreamDone { .. } => MsgKind::StreamDone,
685            Msg::UpstreamError { .. } => MsgKind::UpstreamError,
686            Msg::ToolStarted { .. } => MsgKind::ToolStarted,
687            Msg::ToolProgress { .. } => MsgKind::ToolProgress,
688            Msg::ToolFinished { .. } => MsgKind::ToolFinished,
689            Msg::ApprovalRequested { .. } => MsgKind::ApprovalRequested,
690            Msg::QuestionAsked { .. } => MsgKind::QuestionAsked,
691            Msg::TasksUpdated { .. } => MsgKind::TasksUpdated,
692            Msg::TaskNotice { .. } => MsgKind::TaskNotice,
693            Msg::TurnCancelled(_) => MsgKind::TurnCancelled,
694            Msg::McpServerReady { .. }
695            | Msg::McpServerErrored { .. }
696            | Msg::McpServerStopped { .. } => MsgKind::Mcp,
697            Msg::HookContext { .. } => MsgKind::HookContext,
698            Msg::InstructionsChanged(_) => MsgKind::InstructionsChanged,
699            Msg::MemoryChanged(_) => MsgKind::MemoryChanged,
700            Msg::SessionSaved => MsgKind::SessionSaved,
701            Msg::ConversationLoaded(_) => MsgKind::ConversationLoaded,
702            Msg::ConversationsListed(_) => MsgKind::ConversationsListed,
703            Msg::ProjectFilesListed(_) => MsgKind::ProjectFilesListed,
704            Msg::ScratchpadReady { .. } => MsgKind::ScratchpadReady,
705            Msg::RuntimeTasksListed(_)
706            | Msg::RuntimeTaskLoaded { .. }
707            | Msg::RuntimeProcessesListed(_)
708            | Msg::RuntimeText(_)
709            | Msg::RuntimeApprovalsListed(_)
710            | Msg::RuntimeCheckpointsListed(_)
711            | Msg::ForkCheckpointsFound(_)
712            | Msg::RuntimePluginsListed(_) => MsgKind::RuntimeStore,
713            Msg::ModelPullFinished { .. } => MsgKind::ModelPullFinished,
714            Msg::ModelPullProgress(_) => MsgKind::ModelPullProgress,
715            Msg::Tick => MsgKind::Tick,
716            Msg::Resize { .. } => MsgKind::Resize,
717            Msg::MouseScroll { .. } => MsgKind::MouseScroll,
718            Msg::FocusChanged(_) => MsgKind::FocusChanged,
719            Msg::OpenImageAt { .. } => MsgKind::OpenImageAt,
720            Msg::TransientStatus { .. } => MsgKind::TransientStatus,
721            Msg::EditorReturned { .. } => MsgKind::EditorReturned,
722            Msg::BackgroundAgentStarted { .. }
723            | Msg::BackgroundAgentProgress { .. }
724            | Msg::BackgroundAgentFinished { .. } => MsgKind::BackgroundAgent,
725            Msg::CopySelection(_) => MsgKind::CopySelection,
726        }
727    }
728}
729
730/// Compact kind tag for tracing / replay indexing.
731#[derive(Debug, Clone, Copy, PartialEq, Eq)]
732pub enum MsgKind {
733    Key,
734    Paste,
735    ClipboardRead,
736    SubmitPrompt,
737    Slash,
738    CancelTurn,
739    Confirm,
740    Quit,
741    RuntimeSignal,
742    StreamText,
743    StreamReasoning,
744    StreamToolCall,
745    ContextUsageEstimated,
746    ProviderContextResolved,
747    OllamaPlacementResolved,
748    ProviderVisionResolved,
749    BuiltinToolSchemaTokens,
750    CompactionFinished,
751    CompactionFailed,
752    StreamDone,
753    UpstreamError,
754    ToolStarted,
755    ToolProgress,
756    ToolFinished,
757    ApprovalRequested,
758    QuestionAsked,
759    TasksUpdated,
760    TaskNotice,
761    TurnCancelled,
762    Mcp,
763    InstructionsChanged,
764    HookContext,
765    MemoryChanged,
766    SessionSaved,
767    ConversationLoaded,
768    ConversationsListed,
769    ProjectFilesListed,
770    ScratchpadReady,
771    RuntimeStore,
772    ModelPullFinished,
773    ModelPullProgress,
774    Tick,
775    Resize,
776    MouseScroll,
777    FocusChanged,
778    BackgroundAgent,
779    OpenImageAt,
780    TransientStatus,
781    EditorReturned,
782    CopySelection,
783}
784
785/// Helper for `app::event_source` — pass through the MCP config that
786/// effect::mcp needs to dispatch `InitMcpServers` as its first effect.
787/// Not a `Msg` because it's startup-only.
788#[derive(Debug, Clone)]
789pub struct StartupConfig {
790    pub mcp_servers: std::collections::HashMap<String, McpServerConfig>,
791    pub cwd: PathBuf,
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn turn_id_extracted_from_stream_messages() {
800        let m = Msg::StreamText {
801            turn: TurnId(7),
802            chunk: "hi".to_string(),
803        };
804        assert_eq!(m.turn_id(), Some(TurnId(7)));
805    }
806
807    #[test]
808    fn turn_id_none_for_user_intent() {
809        let m = Msg::CancelTurn;
810        assert_eq!(m.turn_id(), None);
811        let m = Msg::Quit;
812        assert_eq!(m.turn_id(), None);
813        let m = Msg::Tick;
814        assert_eq!(m.turn_id(), None);
815    }
816
817    #[test]
818    fn turn_id_none_for_mcp_lifecycle() {
819        let m = Msg::McpServerReady {
820            name: "s".to_string(),
821            tools: vec![],
822        };
823        assert_eq!(m.turn_id(), None);
824    }
825
826    #[test]
827    fn key_mods_builder_defaults_match_const() {
828        assert_eq!(KeyMods::default(), KeyMods::NONE);
829        assert!(KeyMods::ctrl().ctrl);
830        assert!(!KeyMods::ctrl().alt);
831        assert!(!KeyMods::ctrl().shift);
832    }
833
834    #[test]
835    fn kind_stable_across_variants() {
836        assert_eq!(Msg::Quit.kind(), MsgKind::Quit);
837        assert_eq!(Msg::Tick.kind(), MsgKind::Tick);
838        assert_eq!(
839            Msg::StreamText {
840                turn: TurnId(1),
841                chunk: String::new()
842            }
843            .kind(),
844            MsgKind::StreamText
845        );
846    }
847
848    #[test]
849    fn slash_cmd_carries_none_for_no_arg() {
850        let c = SlashCmd::Model(None);
851        assert_eq!(c, SlashCmd::Model(None));
852        assert_ne!(c, SlashCmd::Model(Some("ollama/qwen3".to_string())));
853    }
854}