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    #[serde(default)]
144    pub artifacts: Vec<ToolArtifact>,
145}
146
147/// Tool outcome status independent of how the result is rendered.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ToolStatus {
151    Success,
152    Error,
153    Cancelled,
154}
155
156/// Typed metadata produced by a specific tool implementation.
157#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
158#[serde(tag = "kind", rename_all = "snake_case")]
159pub enum ToolMetadata {
160    #[default]
161    None,
162    ReadFile {
163        paths: Vec<String>,
164        line_count: usize,
165        byte_count: usize,
166        truncated: bool,
167    },
168    WriteFile {
169        path: String,
170        line_count: usize,
171        byte_count: usize,
172        created: Option<bool>,
173    },
174    EditFile {
175        path: String,
176        replacements: usize,
177    },
178    DeleteFile {
179        path: String,
180    },
181    CreateDirectory {
182        path: String,
183    },
184    WebSearch {
185        queries: Vec<String>,
186        requested_count: usize,
187        result_count: usize,
188        sources: Vec<String>,
189    },
190    WebFetch {
191        url: String,
192        title: Option<String>,
193        line_count: usize,
194        byte_count: usize,
195    },
196    ExecuteCommand {
197        command: String,
198        working_dir: Option<String>,
199        exit_code: Option<i32>,
200        timed_out: bool,
201        background: bool,
202        stdout_lines: usize,
203        stderr_lines: usize,
204        detected_urls: Vec<String>,
205        pid: Option<u32>,
206        log_path: Option<String>,
207    },
208    ComputerUse {
209        action: String,
210        params: Value,
211    },
212    Mcp {
213        server: String,
214        tool: String,
215    },
216    Subagent {
217        model_id: String,
218    },
219    Custom {
220        name: String,
221        data: Value,
222    },
223}
224
225/// Non-text artifact produced by a tool. Images are base64 strings to
226/// match the existing chat-message storage format.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(tag = "kind", rename_all = "snake_case")]
229pub enum ToolArtifact {
230    Image { data: String },
231    File { path: String },
232    Log { path: String },
233}
234
235/// Runtime state that is not part of the chat transcript sent to a
236/// model, but is useful for UI, slash commands, and debugging.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct RuntimeState {
239    pub provider_capabilities: ProviderCapabilitySnapshot,
240    #[serde(default)]
241    pub processes: Vec<ManagedProcess>,
242    #[serde(default)]
243    pub timeline: Vec<RuntimeTimelineEvent>,
244}
245
246impl RuntimeState {
247    pub fn new(model_id: &str) -> Self {
248        Self {
249            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
250            processes: Vec::new(),
251            timeline: Vec::new(),
252        }
253    }
254
255    pub fn set_model(&mut self, model_id: &str) {
256        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
257        self.timeline.push(RuntimeTimelineEvent {
258            kind: RuntimeTimelineKind::Provider,
259            message: format!("model set to {}", model_id),
260        });
261    }
262
263    pub fn record_signal(&mut self, signal: RuntimeSignal) {
264        self.timeline.push(RuntimeTimelineEvent {
265            kind: RuntimeTimelineKind::Signal,
266            message: format!("received {}", signal.as_str()),
267        });
268    }
269
270    pub fn register_process(&mut self, process: ManagedProcess) {
271        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
272            *existing = process.clone();
273        } else {
274            self.processes.push(process.clone());
275        }
276        self.timeline.push(RuntimeTimelineEvent {
277            kind: RuntimeTimelineKind::Process,
278            message: format!("registered process {} ({})", process.pid, process.command),
279        });
280    }
281}
282
283impl Default for RuntimeState {
284    fn default() -> Self {
285        Self::new("ollama/unknown")
286    }
287}