Skip to main content

mermaid_cli/domain/
runtime.rs

1//! Runtime metadata shared by the reducer, recorder, and renderer.
2//!
3//! These types deliberately carry facts rather than presentation
4//! strings. Tool output still contains the provider-facing text that
5//! goes back into the model, while this module holds the metadata the
6//! UI and future commands can consume without scraping that text.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// External lifecycle signal observed by the app shell.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RuntimeSignal {
15    Interrupt,
16    Terminate,
17    Hangup,
18}
19
20impl RuntimeSignal {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            RuntimeSignal::Interrupt => "interrupt",
24            RuntimeSignal::Terminate => "terminate",
25            RuntimeSignal::Hangup => "hangup",
26        }
27    }
28}
29
30/// Runtime event recorded in state for observability / replay tooling.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct RuntimeTimelineEvent {
33    pub kind: RuntimeTimelineKind,
34    pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum RuntimeTimelineKind {
40    Signal,
41    Process,
42    Tool,
43    Provider,
44}
45
46/// Normalized provider capability snapshot exposed in app state.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ProviderCapabilitySnapshot {
49    pub provider: String,
50    pub model: String,
51    pub supports_tools: bool,
52    pub supports_vision: bool,
53    pub reasoning: String,
54    pub max_context_tokens: Option<usize>,
55}
56
57impl ProviderCapabilitySnapshot {
58    /// Conservative static snapshot used before a provider has been
59    /// resolved. This is intentionally cheap and side-effect free so
60    /// the reducer can update it on `/model` without touching network
61    /// or credential state.
62    pub fn from_model_id(model_id: &str) -> Self {
63        let (provider, model) = match model_id.split_once('/') {
64            Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
65                (provider.to_ascii_lowercase(), model.to_string())
66            },
67            _ => ("ollama".to_string(), model_id.to_string()),
68        };
69
70        let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
71            "anthropic" => (true, true, "adaptive".to_string()),
72            "gemini" => (true, true, "thinking_level".to_string()),
73            "ollama" => (true, false, "binary".to_string()),
74            _ => (true, false, "effort".to_string()),
75        };
76
77        let max_context_tokens = infer_static_context_window(&provider, &model);
78
79        Self {
80            provider,
81            model,
82            supports_tools,
83            supports_vision,
84            reasoning,
85            max_context_tokens,
86        }
87    }
88}
89
90fn infer_static_context_window(provider: &str, model: &str) -> Option<usize> {
91    let model = model.to_ascii_lowercase();
92    match provider {
93        "anthropic" => Some(200_000),
94        "gemini" => Some(1_000_000),
95        "openai" if model.contains("gpt-4.1") || model.contains("gpt-5") => Some(400_000),
96        "openrouter" if model.contains("claude") => Some(200_000),
97        _ => None,
98    }
99}
100
101pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
102    let (provider, model) = match model_id.split_once('/') {
103        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
104            (provider.to_ascii_lowercase(), model.to_string())
105        },
106        _ => ("ollama".to_string(), model_id.to_string()),
107    };
108    infer_static_context_window(&provider, &model)
109}
110
111/// Background process status tracked by Mermaid after launching a
112/// command in `execute_command(mode="background")`.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ManagedProcessStatus {
116    Running,
117    Exited,
118    Unknown,
119}
120
121/// Registry record for a background process Mermaid started.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct ManagedProcess {
124    pub id: String,
125    pub pid: u32,
126    pub command: String,
127    pub cwd: Option<String>,
128    pub log_path: String,
129    pub detected_url: Option<String>,
130    pub status: ManagedProcessStatus,
131}
132
133/// Structured metadata extracted from a completed tool run.
134#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
135pub struct ToolRunMetadata {
136    #[serde(default)]
137    pub detail: ToolMetadata,
138    pub line_count: Option<usize>,
139    pub byte_count: Option<usize>,
140    pub result_count: Option<usize>,
141    pub duration_secs: Option<f64>,
142    pub process: Option<ManagedProcess>,
143    /// User-facing display diff for file mutations. This is captured
144    /// at tool execution time so whole-file writes can compare against
145    /// the pre-write contents even after the file has been overwritten.
146    #[serde(default)]
147    pub display_diff: Option<String>,
148    #[serde(default)]
149    pub diff_truncated: bool,
150    #[serde(default)]
151    pub artifacts: Vec<ToolArtifact>,
152}
153
154/// Tool outcome status independent of how the result is rendered.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum ToolStatus {
158    Success,
159    Error,
160    Cancelled,
161}
162
163/// Typed metadata produced by a specific tool implementation.
164#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
165#[serde(tag = "kind", rename_all = "snake_case")]
166pub enum ToolMetadata {
167    #[default]
168    None,
169    ReadFile {
170        paths: Vec<String>,
171        line_count: usize,
172        byte_count: usize,
173        truncated: bool,
174    },
175    WriteFile {
176        path: String,
177        line_count: usize,
178        byte_count: usize,
179        created: Option<bool>,
180    },
181    EditFile {
182        path: String,
183        replacements: usize,
184    },
185    DeleteFile {
186        path: String,
187    },
188    CreateDirectory {
189        path: String,
190    },
191    WebSearch {
192        queries: Vec<String>,
193        requested_count: usize,
194        result_count: usize,
195        sources: Vec<String>,
196    },
197    WebFetch {
198        url: String,
199        title: Option<String>,
200        line_count: usize,
201        byte_count: usize,
202    },
203    ExecuteCommand {
204        command: String,
205        working_dir: Option<String>,
206        exit_code: Option<i32>,
207        timed_out: bool,
208        background: bool,
209        stdout_lines: usize,
210        stderr_lines: usize,
211        detected_urls: Vec<String>,
212        pid: Option<u32>,
213        log_path: Option<String>,
214    },
215    ComputerUse {
216        action: String,
217        params: Value,
218    },
219    Mcp {
220        server: String,
221        tool: String,
222    },
223    Subagent {
224        model_id: String,
225    },
226    Custom {
227        name: String,
228        data: Value,
229    },
230}
231
232/// Non-text artifact produced by a tool. Images are base64 strings to
233/// match the existing chat-message storage format.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(tag = "kind", rename_all = "snake_case")]
236pub enum ToolArtifact {
237    Image { data: String },
238    File { path: String },
239    Log { path: String },
240}
241
242/// Runtime state that is not part of the chat transcript sent to a
243/// model, but is useful for UI, slash commands, and debugging.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct RuntimeState {
246    pub provider_capabilities: ProviderCapabilitySnapshot,
247    #[serde(default)]
248    pub processes: Vec<ManagedProcess>,
249    #[serde(default)]
250    pub timeline: Vec<RuntimeTimelineEvent>,
251    /// Estimated token cost of the built-in tool schemas the effect runner
252    /// appends to every model request during dispatch. The reducer's
253    /// `/context` preview builds an MCP-only request and can't see these, so
254    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
255    /// `/context` folds it in to match what dispatch actually decides.
256    #[serde(default)]
257    pub builtin_tool_schema_tokens: usize,
258}
259
260impl RuntimeState {
261    pub fn new(model_id: &str) -> Self {
262        Self {
263            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
264            processes: Vec::new(),
265            timeline: Vec::new(),
266            builtin_tool_schema_tokens: 0,
267        }
268    }
269
270    pub fn set_model(&mut self, model_id: &str) {
271        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
272        self.timeline.push(RuntimeTimelineEvent {
273            kind: RuntimeTimelineKind::Provider,
274            message: format!("model set to {}", model_id),
275        });
276    }
277
278    pub fn record_signal(&mut self, signal: RuntimeSignal) {
279        self.timeline.push(RuntimeTimelineEvent {
280            kind: RuntimeTimelineKind::Signal,
281            message: format!("received {}", signal.as_str()),
282        });
283    }
284
285    pub fn register_process(&mut self, process: ManagedProcess) {
286        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
287            *existing = process.clone();
288        } else {
289            self.processes.push(process.clone());
290        }
291        self.timeline.push(RuntimeTimelineEvent {
292            kind: RuntimeTimelineKind::Process,
293            message: format!("registered process {} ({})", process.pid, process.command),
294        });
295    }
296}
297
298impl Default for RuntimeState {
299    fn default() -> Self {
300        Self::new("ollama/unknown")
301    }
302}