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