mermaid-cli 0.18.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
//! Runtime metadata shared by the reducer, recorder, and renderer.
//!
//! These types deliberately carry facts rather than presentation
//! strings. Tool output still contains the provider-facing text that
//! goes back into the model, while this module holds the metadata the
//! UI and future commands can consume without scraping that text.

use std::collections::HashSet;

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// External lifecycle signal observed by the app shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeSignal {
    Interrupt,
    Terminate,
    Hangup,
}

impl RuntimeSignal {
    pub fn as_str(self) -> &'static str {
        match self {
            RuntimeSignal::Interrupt => "interrupt",
            RuntimeSignal::Terminate => "terminate",
            RuntimeSignal::Hangup => "hangup",
        }
    }
}

/// Runtime event recorded in state for observability / replay tooling.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeTimelineEvent {
    pub kind: RuntimeTimelineKind,
    pub message: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeTimelineKind {
    Signal,
    Process,
    Tool,
    Provider,
}

/// Normalized provider capability snapshot exposed in app state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderCapabilitySnapshot {
    pub provider: String,
    pub model: String,
    pub supports_tools: bool,
    pub supports_vision: bool,
    pub reasoning: String,
    pub max_context_tokens: Option<usize>,
    /// Per-response output ceiling, when known (static table at model-switch
    /// time; refreshed live via `ProviderContextResolved`).
    #[serde(default)]
    pub max_output_tokens: Option<usize>,
}

impl ProviderCapabilitySnapshot {
    /// Conservative static snapshot used before a provider has been
    /// resolved. This is intentionally cheap and side-effect free so
    /// the reducer can update it on `/model` without touching network
    /// or credential state.
    pub fn from_model_id(model_id: &str) -> Self {
        let (provider, model) = match model_id.split_once('/') {
            Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
                (provider.to_ascii_lowercase(), model.to_string())
            },
            _ => ("ollama".to_string(), model_id.to_string()),
        };

        let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
            "anthropic" => (true, true, "adaptive".to_string()),
            "gemini" => (true, true, "thinking_level".to_string()),
            "meta" => (true, true, "responses_effort".to_string()),
            "ollama" => (true, false, "binary".to_string()),
            _ => (true, false, "effort".to_string()),
        };

        // Meta's muse-spark rides the catalog like the gpt rows (its /v1/models
        // exposes no limits) — no provider special-case here.
        let max_context_tokens = infer_static_context_window(&model);
        // Output ceilings start unknown everywhere and are refreshed live via
        // `ProviderContextResolved` (for meta, from the provider's documented
        // capabilities after the first resolve — same one-turn delay as
        // anthropic/gemini).
        let max_output_tokens = None;

        Self {
            provider,
            model,
            supports_tools,
            supports_vision,
            reasoning,
            max_context_tokens,
            max_output_tokens,
        }
    }
}

fn infer_static_context_window(model: &str) -> Option<usize> {
    // Per-model documented windows from the capability catalog — ONLY for
    // providers whose API exposes no limits (OpenAI's gpt rows). Providers
    // with a models endpoint (Anthropic, Gemini, Ollama, most OpenAI-compat)
    // resolve live via `resolve_context_window`; `None` here means "unknown
    // until discovery", never a guessed fallback.
    crate::models::catalog::lookup(model).context_window
}

pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
    let model = match model_id.split_once('/') {
        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => model,
        _ => model_id,
    };
    infer_static_context_window(model)
}

/// Background process status tracked by Mermaid after launching a
/// command in `execute_command(mode="background")`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManagedProcessStatus {
    Running,
    Exited,
    Unknown,
}

/// Registry record for a background process Mermaid started.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedProcess {
    pub id: String,
    pub pid: u32,
    pub command: String,
    pub cwd: Option<String>,
    pub log_path: String,
    pub detected_url: Option<String>,
    pub status: ManagedProcessStatus,
}

/// Structured metadata extracted from a completed tool run.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolRunMetadata {
    #[serde(default)]
    pub detail: ToolMetadata,
    pub line_count: Option<usize>,
    pub byte_count: Option<usize>,
    pub result_count: Option<usize>,
    pub duration_secs: Option<f64>,
    pub process: Option<ManagedProcess>,
    /// User-facing display diff for file mutations. This is captured
    /// at tool execution time so whole-file writes can compare against
    /// the pre-write contents even after the file has been overwritten.
    #[serde(default)]
    pub display_diff: Option<String>,
    #[serde(default)]
    pub diff_truncated: bool,
    /// Exact line-change counts for file mutations. Carried separately from
    /// `display_diff` because that string is capped at
    /// `MAX_DISPLAY_DIFF_LINES` — recounting it would undercount large
    /// writes. `handle_tool_finished` folds these into the per-run totals
    /// behind the end-of-run `+N/-M` summary.
    #[serde(default)]
    pub lines_added: usize,
    #[serde(default)]
    pub lines_removed: usize,
    #[serde(default)]
    pub artifacts: Vec<ToolArtifact>,
    /// Provider token usage the tool itself consumed (today: a subagent's
    /// cumulative child-session usage). `handle_tool_finished` folds it into
    /// the parent session's totals so the footer and the end-of-run summary
    /// count the whole tree, not just the parent's own model calls.
    #[serde(default)]
    pub token_usage: Option<crate::models::TokenUsage>,
}

/// Tool outcome status independent of how the result is rendered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolStatus {
    Success,
    Error,
    Cancelled,
}

/// Typed metadata produced by a specific tool implementation.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ToolMetadata {
    #[default]
    None,
    ReadFile {
        paths: Vec<String>,
        line_count: usize,
        byte_count: usize,
        truncated: bool,
    },
    WriteFile {
        path: String,
        line_count: usize,
        byte_count: usize,
        created: Option<bool>,
    },
    ApplyPatch {
        added: Vec<String>,
        modified: Vec<String>,
        deleted: Vec<String>,
        renamed: Vec<(String, String)>,
        fuzzy: bool,
    },
    DeleteFile {
        path: String,
    },
    CreateDirectory {
        path: String,
    },
    WebSearch {
        queries: Vec<String>,
        requested_count: usize,
        result_count: usize,
        sources: Vec<String>,
    },
    WebFetch {
        url: String,
        title: Option<String>,
        line_count: usize,
        byte_count: usize,
    },
    ExecuteCommand {
        command: String,
        working_dir: Option<String>,
        exit_code: Option<i32>,
        timed_out: bool,
        background: bool,
        stdout_lines: usize,
        stderr_lines: usize,
        detected_urls: Vec<String>,
        pid: Option<u32>,
        log_path: Option<String>,
        /// The command was terminated by the OS sandbox (e.g. it tried to
        /// reach the network under `--no-network`). Additive; `#[serde(default)]`
        /// keeps older recordings/rows deserializable.
        #[serde(default)]
        denied_by_sandbox: bool,
    },
    ComputerUse {
        action: String,
        params: Value,
    },
    Mcp {
        server: String,
        tool: String,
    },
    Subagent {
        model_id: String,
        /// Continuation handle: pass back via the `agent` tool's `agent_id`
        /// arg to send a follow-up prompt to this child with its context
        /// intact. Empty on recordings from before continuations existed.
        #[serde(default)]
        agent_id: String,
    },
    /// The task checklist tools (`task_create` / `task_update` / `task_list`).
    /// `action` is the wire tool suffix ("create" / "update" / "list");
    /// counts are over visible (non-deleted) tasks after the call.
    Tasks {
        action: String,
        completed: u32,
        total: u32,
    },
    /// `ask_user_question` resolved with answers. Kept structured so the
    /// transcript can replay each question → answer pair rather than a bare
    /// duration.
    Questions {
        answers: Vec<super::question::QuestionAnswer>,
        /// The answers came from remembered cross-session preferences
        /// (`memoryKey`) rather than a live prompt.
        #[serde(default)]
        remembered: bool,
    },
    /// `exit_plan_mode` resolved with an APPROVED plan: the transcript
    /// renders the plan body as a markdown block, and `handle_tool_finished`
    /// keys the post-approval mechanics (clear `session.plan`, seed the
    /// checklist, optionally auto-submit) on this variant. A
    /// request-for-changes outcome carries no metadata.
    Plan {
        /// Plan-file path as shown to the user (project-relative).
        path: String,
        /// The approved plan text, re-read from disk at approval time.
        body: String,
        /// True when the user chose to start implementing immediately.
        #[serde(default)]
        start: bool,
        /// Execution begins in a FRESH conversation seeded with the handoff
        /// preamble + plan (clear-context execute, or a fresh-session
        /// handoff). The exploration context is left behind on disk.
        #[serde(default)]
        fresh: bool,
        /// Handoff variant that copies the transcript into a new
        /// conversation before starting (mutually exclusive with `fresh`).
        #[serde(default)]
        fork: bool,
        /// Handoff: switch the session to this model for execution.
        #[serde(default)]
        model: Option<String>,
    },
    Custom {
        name: String,
        data: Value,
    },
}

/// Non-text artifact produced by a tool. Images are base64 strings to
/// match the existing chat-message storage format.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ToolArtifact {
    Image { data: String },
    File { path: String },
    Log { path: String },
}

/// The resolved Ollama context window for the active model, reported by the
/// effect runner after the first turn. Drives the `/context` display and the
/// truncation quick-fix. `model_max` is the probed architectural window;
/// `effective` is the `num_ctx` we actually send.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OllamaContextInfo {
    pub model_max: Option<usize>,
    pub effective: Option<usize>,
    pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
}

/// Post-turn memory placement of the loaded Ollama model, from `/api/ps`.
/// `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the part
/// resident in VRAM. Volatile (changes when the model reloads), so it lives
/// outside the quasi-static [`OllamaContextInfo`].
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OllamaPlacement {
    pub size_vram_bytes: u64,
    pub total_bytes: u64,
}

impl OllamaPlacement {
    /// True when the model didn't fully fit VRAM and spilled to CPU/RAM (slow).
    pub fn offloaded(&self) -> bool {
        self.size_vram_bytes < self.total_bytes
    }

    /// Rough percentage of the model running on CPU/RAM (0–100). Integer math;
    /// `0` when the footprint is unknown or fully resident.
    pub fn percent_on_cpu(&self) -> u8 {
        if self.total_bytes == 0 {
            return 0;
        }
        let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
        (on_cpu.saturating_mul(100) / self.total_bytes) as u8
    }
}

/// A subagent detached from its turn via Ctrl+B: still running in a
/// spawned task, no longer blocking the parent. Rows render in the live
/// agent panel until `Msg::BackgroundAgentFinished` removes them (and the
/// child's report arrives as a queued message).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackgroundAgent {
    pub agent_id: String,
    pub description: String,
    pub started: std::time::SystemTime,
    #[serde(default)]
    pub activity: String,
    #[serde(default)]
    pub tokens: usize,
}

/// Runtime state that is not part of the chat transcript sent to a
/// model, but is useful for UI, slash commands, and debugging.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeState {
    pub provider_capabilities: ProviderCapabilitySnapshot,
    #[serde(default)]
    pub processes: Vec<ManagedProcess>,
    /// Subagents detached from their turn via Ctrl+B, newest last.
    #[serde(default)]
    pub background_agents: Vec<BackgroundAgent>,
    #[serde(default)]
    pub timeline: Vec<RuntimeTimelineEvent>,
    /// Estimated token cost of the built-in tool schemas the effect runner
    /// appends to every model request during dispatch. The reducer's
    /// `/context` preview builds an MCP-only request and can't see these, so
    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
    /// `/context` folds it in to match what dispatch actually decides.
    #[serde(default)]
    pub builtin_tool_schema_tokens: usize,
    /// Resolved Ollama context window for the active model (`None` until the
    /// first turn probes it, or for non-Ollama providers).
    #[serde(default)]
    pub ollama_context: Option<OllamaContextInfo>,
    /// Post-turn `/api/ps` memory placement for the active model (`None` until a
    /// turn probes it). Volatile, so it's tracked separately from the window.
    #[serde(default)]
    pub ollama_placement: Option<OllamaPlacement>,
    /// Models we've already shown the proactive auto-fit hint for this session.
    /// Session-only (not persisted) so the gentle reminder reappears each launch.
    #[serde(skip)]
    pub hinted_models: HashSet<String>,
    /// Models we've already warned about VRAM offload this session. Session-only,
    /// so the once-per-session warning behaves like the auto-fit hint.
    #[serde(skip)]
    pub offload_warned: HashSet<String>,
    /// Model-call cycles since the task checklist last changed, while a task
    /// sits in_progress. Drives the staleness nudge (see `push_call_model`);
    /// session-only, reset by every `Msg::TasksUpdated`.
    #[serde(skip)]
    pub calls_since_task_update: u32,
    /// Models we've already shown the no-vision-model notice for this session.
    /// Session-only (not persisted), so the one-shot warning behaves like the
    /// auto-fit hint and offload warning.
    #[serde(skip)]
    pub vision_warned: HashSet<String>,
    /// Auto-converge: per-model `num_ctx` that the post-turn `/api/ps` check
    /// found fits VRAM, keyed by model id. Session-only (not persisted) because
    /// it depends on whatever else is using VRAM right now; re-derived each
    /// session. Read by `build_chat_request` below a user override.
    #[serde(skip)]
    pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
    /// When the current user interaction began. One "turn" in Mermaid is a single
    /// model call + its tools; an agentic run spans many such turns (each tool
    /// follow-up mints a fresh `TurnId`). This anchors the spinner's elapsed timer
    /// to the *whole* run so it doesn't reset to 0 at every tool step. Set on
    /// submit, read only while generating/executing tools. Session-only.
    #[serde(skip)]
    pub run_started: Option<std::time::SystemTime>,
    /// Output tokens committed in *completed* phases of the current run
    /// (parent turns, subagents, mid-run compactions), so the spinner's token
    /// counter accumulates across tool steps instead of resetting each model
    /// call. The live phase's char-based estimate is added on top at render
    /// time.
    #[serde(skip)]
    pub run_tokens: RunTokenCounter,
    /// Lines added/removed by file-mutating tools (write_file, apply_patch)
    /// across the whole run, summed from each outcome's exact metadata counts
    /// so the end-of-run summary can show `+N/-M` without the user totting up
    /// per-call diffs. Reset on submit alongside `run_tokens`. Session-only.
    #[serde(skip)]
    pub run_line_changes: RunLineChanges,
    /// Consecutive auto-compact-and-continue recoveries in the current run after a
    /// context-window truncation. Bounded by `settings.compaction.max_truncation_recoveries`
    /// (0 = uncapped) and reset whenever the run makes progress, so it caps only
    /// no-progress thrashing on a too-small window. Session-only.
    #[serde(skip)]
    pub truncation_recoveries: u32,
    /// Consecutive turns in the current run that produced no visible output (no
    /// assistant text and no tool calls) — even if the model spent the turn on
    /// hidden reasoning. Under `MAX_EMPTY_CONTINUATIONS` the run auto-retries the
    /// model call so a stalled turn isn't left silent; at the cap it stops with a
    /// hint. Reset on a fresh run and whenever a turn makes progress. Session-only.
    #[serde(skip)]
    pub empty_continuations: u32,
    /// Consecutive auto-continuations in the current run after a response hit
    /// the provider's per-response OUTPUT cap with window room to spare
    /// (compaction can't help those — the reply is continued in a fresh turn
    /// instead). Bounded by `MAX_OUTPUT_CONTINUATIONS`; reset whenever a turn
    /// ends any other way. Session-only.
    #[serde(skip)]
    pub continue_recoveries: u32,
    /// Auto-threshold compaction failed, so it is paused until a compaction
    /// succeeds, the user runs `/compact`, or the conversation is switched.
    /// Without this, a summarizer that keeps failing (e.g. a model that can't
    /// produce the required checkpoint structure) silently retries — and pays
    /// for — a draft + review model call on every subsequent turn. Session-only.
    #[serde(skip)]
    pub auto_compact_suppressed: bool,
}

/// Output tokens generated by the current run, with provenance. Real
/// provider counts (completion + reasoning) are the norm; a phase whose
/// provider reported no usage falls back to a chars/4 estimate and taints
/// the whole counter, so the run summary can mark the number `~`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RunTokenCounter {
    pub output_tokens: usize,
    pub contains_estimate: bool,
}

impl RunTokenCounter {
    pub fn add_provider(&mut self, tokens: usize) {
        self.output_tokens = self.output_tokens.saturating_add(tokens);
    }

    pub fn add_estimate(&mut self, tokens: usize) {
        self.output_tokens = self.output_tokens.saturating_add(tokens);
        self.contains_estimate = true;
    }
}

/// Lines added/removed by file mutations in the current run.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RunLineChanges {
    pub added: usize,
    pub removed: usize,
}

impl RunLineChanges {
    pub fn add(&mut self, added: usize, removed: usize) {
        self.added = self.added.saturating_add(added);
        self.removed = self.removed.saturating_add(removed);
    }

    pub fn is_empty(&self) -> bool {
        self.added == 0 && self.removed == 0
    }
}

impl RuntimeState {
    pub fn new(model_id: &str) -> Self {
        Self {
            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
            processes: Vec::new(),
            background_agents: Vec::new(),
            timeline: Vec::new(),
            builtin_tool_schema_tokens: 0,
            ollama_context: None,
            ollama_placement: None,
            hinted_models: HashSet::new(),
            offload_warned: HashSet::new(),
            calls_since_task_update: 0,
            vision_warned: HashSet::new(),
            ollama_converged_num_ctx: std::collections::HashMap::new(),
            run_started: None,
            run_tokens: RunTokenCounter::default(),
            run_line_changes: RunLineChanges::default(),
            truncation_recoveries: 0,
            empty_continuations: 0,
            continue_recoveries: 0,
            auto_compact_suppressed: false,
        }
    }

    /// Cap on `timeline` length. It's a recent-activity log for `/runtime` and
    /// the serialized snapshot, not an audit trail, so the oldest events are
    /// trimmed — otherwise it grows monotonically for the session's life (and
    /// it's `#[serde(default)]`, so it would also bloat every saved snapshot).
    const MAX_TIMELINE_EVENTS: usize = 200;

    /// Append a timeline event, trimming the oldest so the log stays bounded.
    fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
        self.timeline.push(RuntimeTimelineEvent { kind, message });
        let len = self.timeline.len();
        if len > Self::MAX_TIMELINE_EVENTS {
            self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
        }
    }

    pub fn set_model(&mut self, model_id: &str) {
        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
        // New model → the resolved window + placement no longer apply; re-probed
        // next turn.
        self.ollama_context = None;
        self.ollama_placement = None;
        // The pause is model-scoped: a summarizer that couldn't produce the
        // checkpoint structure says nothing about the newly selected model,
        // and switching models is the natural user reaction to the failure.
        self.auto_compact_suppressed = false;
        self.push_timeline(
            RuntimeTimelineKind::Provider,
            format!("model set to {}", model_id),
        );
    }

    pub fn record_signal(&mut self, signal: RuntimeSignal) {
        self.push_timeline(
            RuntimeTimelineKind::Signal,
            format!("received {}", signal.as_str()),
        );
    }

    pub fn register_process(&mut self, process: ManagedProcess) {
        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
            *existing = process.clone();
        } else {
            self.processes.push(process.clone());
        }
        self.push_timeline(
            RuntimeTimelineKind::Process,
            format!("registered process {} ({})", process.pid, process.command),
        );
    }
}

impl Default for RuntimeState {
    fn default() -> Self {
        Self::new("ollama/unknown")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn static_context_windows_pin_the_known_matrix() {
        // ONLY the OpenAI gpt rows and Meta's muse-spark keep static windows
        // (their /v1/models expose no limits). Everything else is None —
        // unknown until live discovery via `resolve_context_window` fills it.
        for (id, want) in [
            ("openai/gpt-4.1", Some(400_000)),
            ("openai/gpt-5-mini", Some(400_000)),
            ("openai/gpt-5.6", Some(1_500_000)),
            (
                "meta/muse-spark-1.1",
                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
            ),
            (
                "meta/muse-spark-1.2",
                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
            ),
            ("anthropic/claude-sonnet-4-6", None),
            ("gemini/gemini-2.5-pro", None),
            ("openrouter/anthropic/claude-sonnet-4.5", None),
            ("openai/gpt-4o", None),
            ("ollama/qwen3-coder:30b", None),
            ("anthropic/claude-future-99", None),
            ("anthropic/nova-experimental", None),
        ] {
            assert_eq!(
                infer_static_context_window_for_model_id(id),
                want,
                "window for {id}"
            );
        }
    }

    #[test]
    fn snapshot_limits_start_unknown_before_discovery() {
        // Pre-discovery snapshots carry no window/ceiling for providers
        // with a limits endpoint — `ProviderContextResolved` refreshes them
        // on the first turn. No static pins to rot.
        let snap = ProviderCapabilitySnapshot::from_model_id("anthropic/claude-fable-5");
        assert_eq!(snap.max_context_tokens, None);
        assert_eq!(snap.max_output_tokens, None);
        let snap = ProviderCapabilitySnapshot::from_model_id("gemini/gemini-2.5-pro");
        assert_eq!(snap.max_context_tokens, None);
        assert_eq!(snap.max_output_tokens, None);
        let snap = ProviderCapabilitySnapshot::from_model_id("openai/gpt-4o");
        assert_eq!(snap.max_output_tokens, None);
    }

    #[test]
    fn timeline_is_bounded_and_keeps_most_recent() {
        let mut rt = RuntimeState::new("ollama/test");
        // `new` may seed an initial event; push well past the cap and confirm
        // the log is trimmed to the most recent window rather than growing.
        for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
            rt.record_signal(RuntimeSignal::Interrupt);
        }
        assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
        // The newest event is retained (front-trim keeps the tail).
        assert_eq!(
            rt.timeline.last().map(|e| e.message.as_str()),
            Some("received interrupt")
        );
    }
}