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}