Skip to main content

mermaid_cli/domain/
cmd.rs

1//! Everything the reducer asks the outside world to do.
2//!
3//! `Cmd` values are inert data structures. The reducer returns them
4//! alongside each new `State`; `effect::EffectRunner::dispatch` then
5//! turns them into real work (spawning tokio tasks, writing files,
6//! hitting HTTP endpoints, killing processes). The reducer itself
7//! never performs any I/O.
8//!
9//! This is the "effects as data" pattern from Elm/Redux. Three
10//! payoffs this rewrite relies on:
11//!
12//!   1. **Testable reducer.** Assertions are `state, cmds = update(...)
13//!      ; assert_eq!(cmds[0], Cmd::CallModel { … })`. No tokio, no
14//!      mocks, no filesystem.
15//!   2. **Uniform middleware.** Retry, tracing, rate-limiting wrap the
16//!      dispatcher once instead of being re-implemented per adapter.
17//!   3. **Replayable sessions.** `--record` dumps every `Msg`; the
18//!      effects the reducer asked for are fully determined by the Msg
19//!      log + initial state, so `--replay` is a pure fold.
20
21use std::collections::HashMap;
22use std::path::PathBuf;
23
24use crate::app::McpServerConfig;
25use crate::models::ChatMessage;
26use crate::models::ReasoningLevel;
27use crate::models::tool_call::ToolCall as ModelToolCall;
28use crate::runtime::TaskStatus;
29use crate::session::ConversationHistory;
30
31use super::compaction::{CompactionArchive, CompactionRecord, CompactionRequest};
32use super::ids::{ToolCallId, TurnId};
33use super::runtime::ManagedProcess;
34
35/// A single side-effect request. Most variants are one-shot; `CallModel`
36/// and `ExecuteTool` spawn long-running tasks inside a per-turn
37/// `TurnScope`.
38// Several variants legitimately carry large payloads (a full
39// `ConversationHistory` / `ChatRequest`). Boxing them would churn ~20
40// construction + match sites for no real gain — `Cmd` values are short-lived
41// and moved, not stored in bulk.
42#[allow(clippy::large_enum_variant)]
43#[derive(Debug, Clone)]
44pub enum Cmd {
45    // ── Model + tool execution (the scope-spawning variants) ────────
46    /// Dispatch the next chat request. Effect runner maps this onto
47    /// `ModelProvider::chat` for the session's active provider.
48    CallModel { turn: TurnId, request: ChatRequest },
49    /// Generate a compact context checkpoint without continuing into
50    /// a normal assistant turn.
51    CompactConversation {
52        turn: TurnId,
53        request: CompactionRequest,
54    },
55    /// Run one tool in parallel with any other tools in the same turn.
56    /// The runner wires `ExecContext::token` to the turn's scope so
57    /// `Cmd::CancelScope` aborts them all at once.
58    ///
59    /// `model_id` is the active session's model id at the moment this
60    /// tool call was emitted. The runner passes it into `ExecContext`
61    /// so tools like `SubagentTool` can spawn children against the
62    /// same provider the parent is using.
63    ExecuteTool {
64        turn: TurnId,
65        call_id: ToolCallId,
66        source: ModelToolCall,
67        model_id: String,
68    },
69    /// Cancel every task in the given turn's `TurnScope`. After the
70    /// scope drains, the runner emits a `Msg::StreamDone` (with a
71    /// synthetic "cancelled" marker in usage, or a batch of
72    /// `ToolFinished { outcome: Cancelled }` for tools already running)
73    /// so the reducer can transition back to `Idle`.
74    CancelScope(TurnId),
75
76    // ── Persistence ─────────────────────────────────────────────────
77    /// Save the current conversation to disk. No-op if unchanged since
78    /// last save (effect-side idempotence).
79    SaveConversation(ConversationHistory),
80    /// Persist the raw messages removed by a compaction, then the compacted
81    /// (message-stripped) conversation. Both are written by ONE effect task,
82    /// archive first — only overwriting the conversation if the archive
83    /// persisted — so a failed/lagging archive can never lose messages while
84    /// the stripped conversation is saved over the old one.
85    SaveCompactionArchive {
86        archive: CompactionArchive,
87        record: CompactionRecord,
88        conversation: ConversationHistory,
89    },
90    /// Persist a daemon-visible background process record.
91    SaveProcess(ManagedProcess),
92    /// Persist the active model ID as `last_used_model`.
93    PersistLastModel(String),
94    /// Persist reasoning level tied to a specific model ID.
95    PersistReasoningFor {
96        model_id: String,
97        level: ReasoningLevel,
98    },
99    /// Re-stat `MERMAID.md` (cheap); emits `Msg::InstructionsChanged`
100    /// only when the mtime moved or the file appeared/disappeared.
101    RefreshInstructions,
102    /// Load a specific conversation by ID and emit
103    /// `Msg::ConversationLoaded`. Reducer consumes that event to
104    /// replace the current session.
105    LoadConversation(String),
106    /// Scan the conversations directory for the /load picker. Emits
107    /// `Msg::ConversationsListed` with one `ConversationSummary` per
108    /// saved session (newest first). The reducer transitions to
109    /// `UiMode::ConversationList` and the render shows the picker.
110    ListConversations,
111    /// List durable daemon/runtime tasks.
112    ListRuntimeTasks { limit: usize },
113    /// Load one durable daemon/runtime task and its timeline.
114    LoadRuntimeTask { id: String },
115    /// List durable daemon/runtime background processes.
116    ListRuntimeProcesses { limit: usize },
117    /// Print one durable process log into the conversation.
118    ShowRuntimeProcessLogs { id: String },
119    /// Stop a durable process by pid.
120    StopRuntimeProcess { id: String },
121    /// Restart a durable process using its recorded command/cwd.
122    RestartRuntimeProcess { id: String },
123    /// Open a URL, path, or process target.
124    OpenRuntimeTarget { target: String },
125    /// Show listening ports.
126    ShowRuntimePorts,
127    /// List pending approval records.
128    ListRuntimeApprovals,
129    /// Mark one approval as approved or denied.
130    DecideRuntimeApproval { id: String, decision: String },
131    /// List restore checkpoints.
132    ListRuntimeCheckpoints { limit: usize },
133    /// List project/global memory.
134    ListRuntimeMemory,
135    /// List installed plugins.
136    ListRuntimePlugins,
137    /// Update one durable task's status.
138    UpdateRuntimeTaskStatus {
139        id: String,
140        status: TaskStatus,
141        final_report: Option<String>,
142    },
143    /// Create a shadow checkpoint for explicit paths.
144    CreateRuntimeCheckpoint { paths: Vec<PathBuf> },
145    /// Restore files from a shadow checkpoint.
146    RestoreRuntimeCheckpoint { id: String },
147    /// Persist a project memory entry from the TUI.
148    RememberRuntimeMemory { key: String, value: String },
149    /// Edit an existing memory entry from the TUI.
150    EditRuntimeMemory { id: String, value: String },
151    /// Soft-delete a memory entry from the TUI.
152    ForgetRuntimeMemory { id: String },
153    /// Show provider/model capability information.
154    ShowRuntimeModelInfo { model: String },
155
156    // ── MCP lifecycle ───────────────────────────────────────────────
157    /// Start every configured MCP server; each one emits
158    /// `Msg::McpServerReady` or `Msg::McpServerErrored` as it comes up.
159    InitMcpServers(HashMap<String, McpServerConfig>),
160    /// Stop a running server (e.g. config was removed, or app quit).
161    StopMcpServer { name: String },
162
163    // ── Ollama helpers ──────────────────────────────────────────────
164    /// `ollama pull <model>` with progress → `Msg::ModelPullFinished`.
165    PullOllamaModel { model: String },
166
167    // ── UI side-effects (cross-process) ─────────────────────────────
168    /// `xdg-open` / `open` / `start` on a file path. Used by the
169    /// image-paste preview and the "open in editor" affordance.
170    OpenInSystem(PathBuf),
171
172    // ── Status line ─────────────────────────────────────────────────
173    /// Schedule `Msg::StatusDismiss` after `ms` milliseconds. Reducer
174    /// uses this to self-clear transient status lines.
175    DismissStatusAfter { ms: u64 },
176
177    // ── Attachments ─────────────────────────────────────────────────
178    /// Persist a pasted image to a temp file so the TUI can open it
179    /// via `OpenInSystem`. Emits no follow-up Msg on success; failure
180    /// is a log-and-drop.
181    WriteImageToTemp {
182        path: PathBuf,
183        bytes: Vec<u8>,
184        format: String,
185    },
186
187    /// Read the system clipboard on a blocking task. The per-platform
188    /// dispatch (xclip / wl-paste / pngpaste / PowerShell) can block
189    /// for hundreds of ms on macOS via osascript, so it never runs on
190    /// the reducer thread. Emits `Msg::Paste(Paste::Image|Text)` on
191    /// success; `Msg::TransientStatus` when the clipboard is empty or
192    /// the read fails.
193    ReadClipboard,
194
195    // ── Terminal lifecycle ──────────────────────────────────────────
196    /// Exit the main loop. No reply message — the loop observes
197    /// `state.should_exit` after the reducer returns and breaks out.
198    Exit,
199    /// Write the OSC 2 terminal-title sequence. Reducer diffs
200    /// against `ui.last_title_dispatched` so this only fires on
201    /// actual title changes, not every frame.
202    SetTerminalTitle(String),
203}
204
205/// Inputs a model needs to generate a turn. Built by the reducer from
206/// `Session` + `Settings` + current `MERMAID.md` context. Pure data —
207/// no provider-specific knowledge here (that's in
208/// `providers::model::*::chat`).
209#[derive(Debug, Clone)]
210pub struct ChatRequest {
211    pub model_id: String,
212    pub messages: Vec<ChatMessage>,
213    pub system_prompt: String,
214    /// `MERMAID.md` content to suffix onto the system prompt. `None` if
215    /// no file was loaded for this project.
216    pub instructions: Option<String>,
217    pub reasoning: ReasoningLevel,
218    pub temperature: f32,
219    pub max_tokens: usize,
220    /// Tool definitions advertised to the model. Combination of the
221    /// built-in tool set + any advertised MCP tools from `McpState`.
222    pub tools: Vec<ToolDefinition>,
223}
224
225/// Provider-agnostic tool definition sent in the request. Concrete
226/// adapters (`providers::model::ollama`, etc.) translate this into
227/// whatever wire shape their API expects.
228#[derive(Debug, Clone)]
229pub struct ToolDefinition {
230    pub name: String,
231    pub description: String,
232    pub input_schema: serde_json::Value,
233}
234
235impl ToolDefinition {
236    /// Wire shape: `{type: "function", function: {name, description,
237    /// parameters}}`. This is the OpenAI / Ollama Chat Completions
238    /// format; Anthropic and Gemini adapters translate further from
239    /// here. Single-canonical-shape keeps adapters from drifting.
240    pub fn to_openai_json(&self) -> serde_json::Value {
241        serde_json::json!({
242            "type": "function",
243            "function": {
244                "name": self.name,
245                "description": self.description,
246                "parameters": self.input_schema,
247            }
248        })
249    }
250}
251
252impl Cmd {
253    /// Human-readable tag, for tracing + replay logs. Stable across
254    /// refactors (tests assert against it).
255    pub fn tag(&self) -> &'static str {
256        match self {
257            Cmd::CallModel { .. } => "call_model",
258            Cmd::CompactConversation { .. } => "compact_conversation",
259            Cmd::ExecuteTool { .. } => "execute_tool",
260            Cmd::CancelScope(_) => "cancel_scope",
261            Cmd::SaveConversation(_) => "save_conversation",
262            Cmd::SaveCompactionArchive { .. } => "save_compaction_archive",
263            Cmd::SaveProcess(_) => "save_process",
264            Cmd::PersistLastModel(_) => "persist_last_model",
265            Cmd::PersistReasoningFor { .. } => "persist_reasoning_for",
266            Cmd::RefreshInstructions => "refresh_instructions",
267            Cmd::LoadConversation(_) => "load_conversation",
268            Cmd::ListConversations => "list_conversations",
269            Cmd::ListRuntimeTasks { .. } => "list_runtime_tasks",
270            Cmd::LoadRuntimeTask { .. } => "load_runtime_task",
271            Cmd::ListRuntimeProcesses { .. } => "list_runtime_processes",
272            Cmd::ShowRuntimeProcessLogs { .. } => "show_runtime_process_logs",
273            Cmd::StopRuntimeProcess { .. } => "stop_runtime_process",
274            Cmd::RestartRuntimeProcess { .. } => "restart_runtime_process",
275            Cmd::OpenRuntimeTarget { .. } => "open_runtime_target",
276            Cmd::ShowRuntimePorts => "show_runtime_ports",
277            Cmd::ListRuntimeApprovals => "list_runtime_approvals",
278            Cmd::DecideRuntimeApproval { .. } => "decide_runtime_approval",
279            Cmd::ListRuntimeCheckpoints { .. } => "list_runtime_checkpoints",
280            Cmd::ListRuntimeMemory => "list_runtime_memory",
281            Cmd::ListRuntimePlugins => "list_runtime_plugins",
282            Cmd::UpdateRuntimeTaskStatus { .. } => "update_runtime_task_status",
283            Cmd::CreateRuntimeCheckpoint { .. } => "create_runtime_checkpoint",
284            Cmd::RestoreRuntimeCheckpoint { .. } => "restore_runtime_checkpoint",
285            Cmd::RememberRuntimeMemory { .. } => "remember_runtime_memory",
286            Cmd::EditRuntimeMemory { .. } => "edit_runtime_memory",
287            Cmd::ForgetRuntimeMemory { .. } => "forget_runtime_memory",
288            Cmd::ShowRuntimeModelInfo { .. } => "show_runtime_model_info",
289            Cmd::InitMcpServers(_) => "init_mcp_servers",
290            Cmd::StopMcpServer { .. } => "stop_mcp_server",
291            Cmd::PullOllamaModel { .. } => "pull_ollama_model",
292            Cmd::OpenInSystem(_) => "open_in_system",
293            Cmd::DismissStatusAfter { .. } => "dismiss_status_after",
294            Cmd::WriteImageToTemp { .. } => "write_image_to_temp",
295            Cmd::ReadClipboard => "read_clipboard",
296            Cmd::Exit => "exit",
297            Cmd::SetTerminalTitle(_) => "set_terminal_title",
298        }
299    }
300
301    /// True iff this command needs to run inside a `TurnScope` so it
302    /// can be cancelled by `Cmd::CancelScope`. The effect runner uses
303    /// this to decide between "spawn into `JoinSet`" and "spawn detached".
304    pub fn is_turn_scoped(&self) -> bool {
305        matches!(
306            self,
307            Cmd::CallModel { .. } | Cmd::CompactConversation { .. } | Cmd::ExecuteTool { .. }
308        )
309    }
310
311    /// For traces + the `--record` file — some `Cmd` payloads are huge
312    /// (think `ChatRequest::messages`). This returns a compact
313    /// identifier that doesn't dump the full payload.
314    pub fn summary(&self) -> String {
315        match self {
316            Cmd::CallModel { turn, request } => format!(
317                "call_model(turn={}, model={}, msgs={})",
318                turn,
319                request.model_id,
320                request.messages.len()
321            ),
322            Cmd::CompactConversation { turn, request } => format!(
323                "compact_conversation(turn={}, model={}, trigger={}, msgs={})",
324                turn,
325                request.chat.model_id,
326                request.trigger.as_str(),
327                request.chat.messages.len()
328            ),
329            Cmd::ExecuteTool {
330                turn,
331                call_id,
332                source,
333                model_id: _,
334            } => format!(
335                "execute_tool(turn={}, call={}, fn={})",
336                turn, call_id, source.function.name
337            ),
338            Cmd::CancelScope(turn) => format!("cancel_scope(turn={})", turn),
339            Cmd::SaveConversation(c) => format!("save_conversation(id={})", c.id),
340            Cmd::SaveCompactionArchive {
341                archive, record, ..
342            } => format!(
343                "save_compaction_archive(conversation={}, id={})",
344                archive.conversation_id, record.id
345            ),
346            Cmd::SaveProcess(p) => format!("save_process(id={}, pid={})", p.id, p.pid),
347            Cmd::PersistLastModel(m) => format!("persist_last_model({})", m),
348            Cmd::PersistReasoningFor { model_id, level } => {
349                format!("persist_reasoning_for({}, {:?})", model_id, level)
350            },
351            Cmd::RefreshInstructions => "refresh_instructions".to_string(),
352            Cmd::LoadConversation(id) => format!("load_conversation({})", id),
353            Cmd::ListConversations => "list_conversations".to_string(),
354            Cmd::ListRuntimeTasks { limit } => format!("list_runtime_tasks(limit={})", limit),
355            Cmd::LoadRuntimeTask { id } => format!("load_runtime_task({})", id),
356            Cmd::ListRuntimeProcesses { limit } => {
357                format!("list_runtime_processes(limit={})", limit)
358            },
359            Cmd::ShowRuntimeProcessLogs { id } => format!("show_runtime_process_logs({})", id),
360            Cmd::StopRuntimeProcess { id } => format!("stop_runtime_process({})", id),
361            Cmd::RestartRuntimeProcess { id } => format!("restart_runtime_process({})", id),
362            Cmd::OpenRuntimeTarget { target } => format!("open_runtime_target({})", target),
363            Cmd::ShowRuntimePorts => "show_runtime_ports".to_string(),
364            Cmd::ListRuntimeApprovals => "list_runtime_approvals".to_string(),
365            Cmd::DecideRuntimeApproval { id, decision } => {
366                format!("decide_runtime_approval({}, {})", id, decision)
367            },
368            Cmd::ListRuntimeCheckpoints { limit } => {
369                format!("list_runtime_checkpoints(limit={})", limit)
370            },
371            Cmd::ListRuntimeMemory => "list_runtime_memory".to_string(),
372            Cmd::ListRuntimePlugins => "list_runtime_plugins".to_string(),
373            Cmd::UpdateRuntimeTaskStatus { id, status, .. } => {
374                format!("update_runtime_task_status({}, {})", id, status)
375            },
376            Cmd::CreateRuntimeCheckpoint { paths } => {
377                format!("create_runtime_checkpoint(n={})", paths.len())
378            },
379            Cmd::RestoreRuntimeCheckpoint { id } => format!("restore_runtime_checkpoint({})", id),
380            Cmd::RememberRuntimeMemory { key, .. } => {
381                format!("remember_runtime_memory({})", key)
382            },
383            Cmd::EditRuntimeMemory { id, .. } => format!("edit_runtime_memory({})", id),
384            Cmd::ForgetRuntimeMemory { id } => format!("forget_runtime_memory({})", id),
385            Cmd::ShowRuntimeModelInfo { model } => format!("show_runtime_model_info({})", model),
386            Cmd::InitMcpServers(m) => format!("init_mcp_servers(n={})", m.len()),
387            Cmd::StopMcpServer { name } => format!("stop_mcp_server({})", name),
388            Cmd::PullOllamaModel { model } => format!("pull_ollama_model({})", model),
389            Cmd::OpenInSystem(p) => format!("open_in_system({})", p.display()),
390            Cmd::DismissStatusAfter { ms } => format!("dismiss_status_after({}ms)", ms),
391            Cmd::WriteImageToTemp {
392                path,
393                format,
394                bytes,
395            } => format!(
396                "write_image_to_temp(path={}, fmt={}, n={})",
397                path.display(),
398                format,
399                bytes.len()
400            ),
401            Cmd::ReadClipboard => "read_clipboard".to_string(),
402            Cmd::Exit => "exit".to_string(),
403            Cmd::SetTerminalTitle(t) => format!("set_terminal_title({})", t),
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn turn_scoped_variants_marked_correctly() {
414        let request = ChatRequest {
415            model_id: "m".to_string(),
416            messages: vec![],
417            system_prompt: String::new(),
418            instructions: None,
419            reasoning: ReasoningLevel::Medium,
420            temperature: 0.7,
421            max_tokens: 4096,
422            tools: vec![],
423        };
424        assert!(
425            Cmd::CallModel {
426                turn: TurnId(1),
427                request,
428            }
429            .is_turn_scoped()
430        );
431        assert!(
432            !Cmd::SaveConversation(ConversationHistory::new("/p".to_string(), "m".to_string()))
433                .is_turn_scoped()
434        );
435        assert!(!Cmd::RefreshInstructions.is_turn_scoped());
436        assert!(!Cmd::Exit.is_turn_scoped());
437    }
438
439    #[test]
440    fn cmd_tags_are_stable() {
441        assert_eq!(Cmd::Exit.tag(), "exit");
442        assert_eq!(Cmd::RefreshInstructions.tag(), "refresh_instructions");
443        assert_eq!(Cmd::CancelScope(TurnId(1)).tag(), "cancel_scope");
444    }
445
446    #[test]
447    fn cmd_summary_includes_identifying_fields() {
448        let c = Cmd::CancelScope(TurnId(42));
449        let s = c.summary();
450        assert!(s.contains("turn#42"));
451    }
452}