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}