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::session::ConversationHistory;
29
30use super::compaction::{CompactionArchive, CompactionRequest};
31use super::ids::{ToolCallId, TurnId};
32
33/// A single side-effect request. Most variants are one-shot; `CallModel`
34/// and `ExecuteTool` spawn long-running tasks inside a per-turn
35/// `TurnScope`.
36#[derive(Debug, Clone)]
37pub enum Cmd {
38 // ── Model + tool execution (the scope-spawning variants) ────────
39 /// Dispatch the next chat request. Effect runner maps this onto
40 /// `ModelProvider::chat` for the session's active provider.
41 CallModel { turn: TurnId, request: ChatRequest },
42 /// Generate a compact context checkpoint without continuing into
43 /// a normal assistant turn.
44 CompactConversation {
45 turn: TurnId,
46 request: CompactionRequest,
47 },
48 /// Run one tool in parallel with any other tools in the same turn.
49 /// The runner wires `ExecContext::token` to the turn's scope so
50 /// `Cmd::CancelScope` aborts them all at once.
51 ///
52 /// `model_id` is the active session's model id at the moment this
53 /// tool call was emitted. The runner passes it into `ExecContext`
54 /// so tools like `SubagentTool` can spawn children against the
55 /// same provider the parent is using.
56 ExecuteTool {
57 turn: TurnId,
58 call_id: ToolCallId,
59 source: ModelToolCall,
60 model_id: String,
61 },
62 /// Cancel every task in the given turn's `TurnScope`. After the
63 /// scope drains, the runner emits a `Msg::StreamDone` (with a
64 /// synthetic "cancelled" marker in usage, or a batch of
65 /// `ToolFinished { outcome: Cancelled }` for tools already running)
66 /// so the reducer can transition back to `Idle`.
67 CancelScope(TurnId),
68
69 // ── Persistence ─────────────────────────────────────────────────
70 /// Save the current conversation to disk. No-op if unchanged since
71 /// last save (effect-side idempotence).
72 SaveConversation(ConversationHistory),
73 /// Persist the raw messages removed by a compaction.
74 SaveCompactionArchive(CompactionArchive),
75 /// Persist the active model ID as `last_used_model`.
76 PersistLastModel(String),
77 /// Persist reasoning level tied to a specific model ID.
78 PersistReasoningFor {
79 model_id: String,
80 level: ReasoningLevel,
81 },
82 /// Re-stat `MERMAID.md` (cheap); emits `Msg::InstructionsChanged`
83 /// only when the mtime moved or the file appeared/disappeared.
84 RefreshInstructions,
85 /// Load a specific conversation by ID and emit
86 /// `Msg::ConversationLoaded`. Reducer consumes that event to
87 /// replace the current session.
88 LoadConversation(String),
89 /// Scan the conversations directory for the /load picker. Emits
90 /// `Msg::ConversationsListed` with one `ConversationSummary` per
91 /// saved session (newest first). The reducer transitions to
92 /// `UiMode::ConversationList` and the render shows the picker.
93 ListConversations,
94
95 // ── MCP lifecycle ───────────────────────────────────────────────
96 /// Start every configured MCP server; each one emits
97 /// `Msg::McpServerReady` or `Msg::McpServerErrored` as it comes up.
98 InitMcpServers(HashMap<String, McpServerConfig>),
99 /// Stop a running server (e.g. config was removed, or app quit).
100 StopMcpServer { name: String },
101
102 // ── Ollama helpers ──────────────────────────────────────────────
103 /// `ollama pull <model>` with progress → `Msg::ModelPullFinished`.
104 PullOllamaModel { model: String },
105
106 // ── UI side-effects (cross-process) ─────────────────────────────
107 /// `xdg-open` / `open` / `start` on a file path. Used by the
108 /// image-paste preview and the "open in editor" affordance.
109 OpenInSystem(PathBuf),
110
111 // ── Status line ─────────────────────────────────────────────────
112 /// Schedule `Msg::StatusDismiss` after `ms` milliseconds. Reducer
113 /// uses this to self-clear transient status lines.
114 DismissStatusAfter { ms: u64 },
115
116 // ── Attachments ─────────────────────────────────────────────────
117 /// Persist a pasted image to a temp file so the TUI can open it
118 /// via `OpenInSystem`. Emits no follow-up Msg on success; failure
119 /// is a log-and-drop.
120 WriteImageToTemp {
121 path: PathBuf,
122 bytes: Vec<u8>,
123 format: String,
124 },
125
126 /// Read the system clipboard on a blocking task. The per-platform
127 /// dispatch (xclip / wl-paste / pngpaste / PowerShell) can block
128 /// for hundreds of ms on macOS via osascript, so it never runs on
129 /// the reducer thread. Emits `Msg::Paste(Paste::Image|Text)` on
130 /// success; `Msg::TransientStatus` when the clipboard is empty or
131 /// the read fails.
132 ReadClipboard,
133
134 // ── Terminal lifecycle ──────────────────────────────────────────
135 /// Exit the main loop. No reply message — the loop observes
136 /// `state.should_exit` after the reducer returns and breaks out.
137 Exit,
138 /// Write the OSC 2 terminal-title sequence. Reducer diffs
139 /// against `ui.last_title_dispatched` so this only fires on
140 /// actual title changes, not every frame.
141 SetTerminalTitle(String),
142}
143
144/// Inputs a model needs to generate a turn. Built by the reducer from
145/// `Session` + `Settings` + current `MERMAID.md` context. Pure data —
146/// no provider-specific knowledge here (that's in
147/// `providers::model::*::chat`).
148#[derive(Debug, Clone)]
149pub struct ChatRequest {
150 pub model_id: String,
151 pub messages: Vec<ChatMessage>,
152 pub system_prompt: String,
153 /// `MERMAID.md` content to suffix onto the system prompt. `None` if
154 /// no file was loaded for this project.
155 pub instructions: Option<String>,
156 pub reasoning: ReasoningLevel,
157 pub temperature: f32,
158 pub max_tokens: usize,
159 /// Tool definitions advertised to the model. Combination of the
160 /// built-in tool set + any advertised MCP tools from `McpState`.
161 pub tools: Vec<ToolDefinition>,
162}
163
164/// Provider-agnostic tool definition sent in the request. Concrete
165/// adapters (`providers::model::ollama`, etc.) translate this into
166/// whatever wire shape their API expects.
167#[derive(Debug, Clone)]
168pub struct ToolDefinition {
169 pub name: String,
170 pub description: String,
171 pub input_schema: serde_json::Value,
172}
173
174impl ToolDefinition {
175 /// Wire shape: `{type: "function", function: {name, description,
176 /// parameters}}`. This is the OpenAI / Ollama Chat Completions
177 /// format; Anthropic and Gemini adapters translate further from
178 /// here. Single-canonical-shape keeps adapters from drifting.
179 pub fn to_openai_json(&self) -> serde_json::Value {
180 serde_json::json!({
181 "type": "function",
182 "function": {
183 "name": self.name,
184 "description": self.description,
185 "parameters": self.input_schema,
186 }
187 })
188 }
189}
190
191impl Cmd {
192 /// Human-readable tag, for tracing + replay logs. Stable across
193 /// refactors (tests assert against it).
194 pub fn tag(&self) -> &'static str {
195 match self {
196 Cmd::CallModel { .. } => "call_model",
197 Cmd::CompactConversation { .. } => "compact_conversation",
198 Cmd::ExecuteTool { .. } => "execute_tool",
199 Cmd::CancelScope(_) => "cancel_scope",
200 Cmd::SaveConversation(_) => "save_conversation",
201 Cmd::SaveCompactionArchive(_) => "save_compaction_archive",
202 Cmd::PersistLastModel(_) => "persist_last_model",
203 Cmd::PersistReasoningFor { .. } => "persist_reasoning_for",
204 Cmd::RefreshInstructions => "refresh_instructions",
205 Cmd::LoadConversation(_) => "load_conversation",
206 Cmd::ListConversations => "list_conversations",
207 Cmd::InitMcpServers(_) => "init_mcp_servers",
208 Cmd::StopMcpServer { .. } => "stop_mcp_server",
209 Cmd::PullOllamaModel { .. } => "pull_ollama_model",
210 Cmd::OpenInSystem(_) => "open_in_system",
211 Cmd::DismissStatusAfter { .. } => "dismiss_status_after",
212 Cmd::WriteImageToTemp { .. } => "write_image_to_temp",
213 Cmd::ReadClipboard => "read_clipboard",
214 Cmd::Exit => "exit",
215 Cmd::SetTerminalTitle(_) => "set_terminal_title",
216 }
217 }
218
219 /// True iff this command needs to run inside a `TurnScope` so it
220 /// can be cancelled by `Cmd::CancelScope`. The effect runner uses
221 /// this to decide between "spawn into `JoinSet`" and "spawn detached".
222 pub fn is_turn_scoped(&self) -> bool {
223 matches!(
224 self,
225 Cmd::CallModel { .. } | Cmd::CompactConversation { .. } | Cmd::ExecuteTool { .. }
226 )
227 }
228
229 /// For traces + the `--record` file — some `Cmd` payloads are huge
230 /// (think `ChatRequest::messages`). This returns a compact
231 /// identifier that doesn't dump the full payload.
232 pub fn summary(&self) -> String {
233 match self {
234 Cmd::CallModel { turn, request } => format!(
235 "call_model(turn={}, model={}, msgs={})",
236 turn,
237 request.model_id,
238 request.messages.len()
239 ),
240 Cmd::CompactConversation { turn, request } => format!(
241 "compact_conversation(turn={}, model={}, trigger={}, msgs={})",
242 turn,
243 request.chat.model_id,
244 request.trigger.as_str(),
245 request.chat.messages.len()
246 ),
247 Cmd::ExecuteTool {
248 turn,
249 call_id,
250 source,
251 model_id: _,
252 } => format!(
253 "execute_tool(turn={}, call={}, fn={})",
254 turn, call_id, source.function.name
255 ),
256 Cmd::CancelScope(turn) => format!("cancel_scope(turn={})", turn),
257 Cmd::SaveConversation(c) => format!("save_conversation(id={})", c.id),
258 Cmd::SaveCompactionArchive(a) => format!(
259 "save_compaction_archive(conversation={}, id={})",
260 a.conversation_id, a.id
261 ),
262 Cmd::PersistLastModel(m) => format!("persist_last_model({})", m),
263 Cmd::PersistReasoningFor { model_id, level } => {
264 format!("persist_reasoning_for({}, {:?})", model_id, level)
265 },
266 Cmd::RefreshInstructions => "refresh_instructions".to_string(),
267 Cmd::LoadConversation(id) => format!("load_conversation({})", id),
268 Cmd::ListConversations => "list_conversations".to_string(),
269 Cmd::InitMcpServers(m) => format!("init_mcp_servers(n={})", m.len()),
270 Cmd::StopMcpServer { name } => format!("stop_mcp_server({})", name),
271 Cmd::PullOllamaModel { model } => format!("pull_ollama_model({})", model),
272 Cmd::OpenInSystem(p) => format!("open_in_system({})", p.display()),
273 Cmd::DismissStatusAfter { ms } => format!("dismiss_status_after({}ms)", ms),
274 Cmd::WriteImageToTemp {
275 path,
276 format,
277 bytes,
278 } => format!(
279 "write_image_to_temp(path={}, fmt={}, n={})",
280 path.display(),
281 format,
282 bytes.len()
283 ),
284 Cmd::ReadClipboard => "read_clipboard".to_string(),
285 Cmd::Exit => "exit".to_string(),
286 Cmd::SetTerminalTitle(t) => format!("set_terminal_title({})", t),
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn turn_scoped_variants_marked_correctly() {
297 let request = ChatRequest {
298 model_id: "m".to_string(),
299 messages: vec![],
300 system_prompt: String::new(),
301 instructions: None,
302 reasoning: ReasoningLevel::Medium,
303 temperature: 0.7,
304 max_tokens: 4096,
305 tools: vec![],
306 };
307 assert!(
308 Cmd::CallModel {
309 turn: TurnId(1),
310 request,
311 }
312 .is_turn_scoped()
313 );
314 assert!(
315 !Cmd::SaveConversation(ConversationHistory::new("/p".to_string(), "m".to_string()))
316 .is_turn_scoped()
317 );
318 assert!(!Cmd::RefreshInstructions.is_turn_scoped());
319 assert!(!Cmd::Exit.is_turn_scoped());
320 }
321
322 #[test]
323 fn cmd_tags_are_stable() {
324 assert_eq!(Cmd::Exit.tag(), "exit");
325 assert_eq!(Cmd::RefreshInstructions.tag(), "refresh_instructions");
326 assert_eq!(Cmd::CancelScope(TurnId(1)).tag(), "cancel_scope");
327 }
328
329 #[test]
330 fn cmd_summary_includes_identifying_fields() {
331 let c = Cmd::CancelScope(TurnId(42));
332 let s = c.summary();
333 assert!(s.contains("turn#42"));
334 }
335}