mermaid-cli 0.30.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
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
//! `/model` picker — renders the bottom zone when `UiMode::ModelPicker` is
//! active.
//!
//! Shaped like the `/load` and `/plan config` panes (bordered, arrow-selectable)
//! with two additions the model list actually needs:
//!
//!   * **Group headings.** Local Ollama models and each remote provider are
//!     visually separated, so "what runs on my machine" is answerable at a
//!     glance — the distinction a sovereignty-focused tool most owes its user.
//!     The heading is sticky: a window scrolled into the middle of a hundred-row
//!     provider block still names that provider on its first line.
//!   * **Rows without the provider prefix.** The heading already says `nvidia`,
//!     so the row says `mistralai/mistral-large-2-instruct` — and NVIDIA's own
//!     models stop reading `nvidia/nvidia/…`. Nothing is lost: the footer
//!     spells the highlighted row out as the full id `/model` takes.
//!   * **A filter line.** A provider's `/models` endpoint routinely returns
//!     100+ ids. A fixed list of four would be a lie about what is available,
//!     and an unfiltered list of two hundred is unusable; typing narrows it.

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use unicode_width::UnicodeWidthStr;

use crate::render::theme::Theme;
use mermaid_domain::ModelChoice;

/// Rows drawn at once. Enough to see a provider's block without swallowing the
/// transcript; the window scrolls with the cursor beyond that.
pub const MODEL_PICKER_VISIBLE_ROWS: usize = 10;

/// Total pane height including borders and the filter line.
pub const MODEL_PICKER_HEIGHT: u16 = MODEL_PICKER_VISIBLE_ROWS as u16 + 3;

pub struct ModelPickerWidget<'a> {
    pub theme: &'a Theme,
    /// Rows that survived the filter, in display order.
    pub matches: &'a [&'a ModelChoice],
    /// The live filter text.
    pub query: &'a str,
    pub cursor: usize,
    /// Discovery still running — distinguishes "looking" from "none found".
    pub loading: bool,
    /// The session's active model, marked so the picker always answers "what am
    /// I on right now?" without a second command.
    pub current: &'a str,
}

impl<'a> Widget for ModelPickerWidget<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let c = &self.theme.colors;
        let dim = Style::default().fg(c.text_disabled.to_color());
        let block = Block::default()
            .borders(Borders::ALL)
            .title("Select model — ↑↓ navigate · Enter switch · type to filter · Esc cancel")
            .border_style(Style::default().fg(c.border.to_color()));

        let inner_height = area.height.saturating_sub(2) as usize;
        // One line goes to the filter/status row at the bottom.
        let visible = inner_height
            .saturating_sub(1)
            .min(MODEL_PICKER_VISIBLE_ROWS);
        let width = area.width.saturating_sub(2) as usize;
        // A cursor past the end would only come from a stale frame; clamp
        // rather than panic on the index.
        let cursor = self.cursor.min(self.matches.len().saturating_sub(1));

        let mut lines: Vec<Line<'static>> = Vec::new();
        if self.matches.is_empty() {
            lines.push(Line::from(Span::styled(
                if self.loading {
                    "  searching for available models…".to_string()
                } else if self.query.is_empty() {
                    "  No models found. Pull one with `ollama pull`, or set a provider API key."
                        .to_string()
                } else {
                    format!("  Nothing matches {:?}.", self.query)
                },
                dim,
            )));
        } else {
            let start = window_start(self.matches, cursor, visible);
            let mut last_group: Option<&str> = None;
            for (i, choice) in self.matches.iter().enumerate().skip(start) {
                if lines.len() >= visible {
                    break;
                }
                if last_group != Some(choice.group.as_str()) {
                    last_group = Some(choice.group.as_str());
                    // A heading on every group change — and on the first
                    // visible row even mid-group, because the rows no longer
                    // carry the provider themselves.
                    if lines.len() + 2 > visible {
                        // No room for the heading AND its row. Stop rather
                        // than draw the row under the heading above it: an
                        // nvidia model tucked under `meta` is a worse lie
                        // than a blank last line. The one exception is a pane
                        // so short nothing has been drawn yet, where the
                        // cursor's own row still has to appear.
                        if !lines.is_empty() {
                            break;
                        }
                    } else {
                        lines.push(Line::from(Span::styled(
                            format!(" {}", choice.group),
                            Style::default()
                                .fg(c.header.to_color())
                                .add_modifier(Modifier::BOLD),
                        )));
                    }
                }
                lines.push(row(choice, i == cursor, self.current, width, self.theme));
            }
        }

        // Filter / status footer.
        let status = if self.query.is_empty() {
            let shown = self.matches.len();
            if self.loading {
                " filter: (type to narrow) · still searching…".to_string()
            } else {
                format!(" filter: (type to narrow) · {shown} models")
            }
        } else {
            format!(
                " filter: {} · {} match{}",
                self.query,
                self.matches.len(),
                if self.matches.len() == 1 { "" } else { "es" }
            )
        };
        // The highlighted row's id, spelled out in full — the exact string
        // `/model` and `--model` take, which the rows themselves no longer
        // show. When the pane is too narrow for both, the id wins: the count
        // is a nicety, the id is the thing this line exists for.
        let footer = match self.matches.get(cursor) {
            Some(choice) => {
                let both = format!("{status} · {}", choice.id);
                if both.width() <= width {
                    both
                } else {
                    super::truncate_to_cells(&format!(" {}", choice.id), width)
                }
            },
            None => status,
        };
        lines.push(Line::from(Span::styled(footer, dim)));

        Paragraph::new(lines).block(block).render(area, buf);
    }
}

/// First row of the scroll window.
///
/// Not `cursor + 1 - visible`: headings share the pane with rows, so a group
/// boundary inside the window costs a line, and row-only arithmetic pushed the
/// highlighted row past `visible` — where the truncate silently ate it and the
/// picker showed no cursor at all. Walk up from the cursor instead, paying for
/// every row and every heading, and stop when the budget runs out.
fn window_start(matches: &[&ModelChoice], cursor: usize, visible: usize) -> usize {
    let mut start = cursor;
    // The cursor's own row, plus the heading that always sits above it.
    let mut cost = 2usize;
    while start > 0 {
        // Extending upward always adds a row. It adds a heading too only when
        // the row above belongs to another group; within one group the heading
        // already paid for simply moves up.
        let extra = if matches[start - 1].group == matches[start].group {
            1
        } else {
            2
        };
        if cost + extra > visible {
            break;
        }
        cost += extra;
        start -= 1;
    }
    start
}

/// Row text: the id minus the provider segment its heading already states, so
/// `nvidia/mistralai/mistral-large-2-instruct` under the `nvidia` heading reads
/// `mistralai/mistral-large-2-instruct` and NVIDIA's own models stop stuttering
/// `nvidia/nvidia/…`.
///
/// The vendor namespace stays. It is part of the id NIM, OpenRouter, Together
/// and DeepInfra actually take, and it is what tells `mistralai/…` apart from
/// `moonshotai/…`. Only a prefix the heading names is dropped — anything else
/// renders whole rather than hiding a mismatch the user has no way to see.
fn display_id(choice: &ModelChoice) -> &str {
    let Some((prefix, rest)) = choice.id.split_once('/') else {
        return &choice.id;
    };
    if rest.is_empty() {
        return &choice.id;
    }
    let group = choice.group.to_ascii_lowercase();
    let prefix = prefix.to_ascii_lowercase();
    // A remote group IS the provider name; the local group spells it out as
    // `Local (Ollama)`.
    if group == prefix || group.contains(&format!("({prefix})")) {
        rest
    } else {
        &choice.id
    }
}

/// One model row: cursor, id, a `(current)` tag when it is the active model,
/// and the dim detail column right-padded to the pane width.
fn row(
    choice: &ModelChoice,
    highlighted: bool,
    current: &str,
    width: usize,
    theme: &Theme,
) -> Line<'static> {
    let c = &theme.colors;
    let prefix = if highlighted { " > " } else { "   " };
    let id_style = if highlighted {
        Style::default()
            .fg(c.brand.to_color())
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(c.text_primary.to_color())
    };
    // Suffixes are fixed-cost and must survive; the id yields to them. An
    // openrouter id can be 60+ cells on its own, so truncating it is the only
    // way the row fits — and the marker that says "this is your current model"
    // is worth more than the tail of a name.
    // Spelled out rather than a check glyph: Mermaid's output is deliberately
    // emoji-free (enforced by `.github/scripts/check_no_emoji.py`, which flags
    // the whole dingbats block), and a word survives truncation legibly anyway.
    let current_mark = if choice.id == current {
        " (current)"
    } else {
        ""
    };
    let pull_mark = if choice.ready { "" } else { " (not pulled)" };
    let reserved = prefix.width() + current_mark.width() + pull_mark.width();
    let id = super::truncate_to_cells(display_id(choice), width.saturating_sub(reserved));

    let mut spans = vec![
        Span::styled(prefix, Style::default().fg(c.brand.to_color())),
        Span::styled(id, id_style),
    ];
    if !current_mark.is_empty() {
        spans.push(Span::styled(
            current_mark,
            Style::default().fg(c.success.to_color()),
        ));
    }
    if !pull_mark.is_empty() {
        spans.push(Span::styled(
            pull_mark,
            Style::default().fg(c.warning.to_color()),
        ));
    }
    // The detail column is a nicety: right-align it only when the row has room
    // left over, and drop it entirely otherwise.
    if !choice.detail.is_empty() {
        let used: usize = spans.iter().map(|s| s.content.width()).sum();
        let detail_width = choice.detail.width();
        if used + detail_width + 2 <= width {
            spans.push(Span::raw(" ".repeat(width - used - detail_width - 1)));
            spans.push(Span::styled(
                choice.detail.clone(),
                Style::default().fg(c.text_disabled.to_color()),
            ));
        }
    }
    Line::from(spans)
}

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

    fn choice(id: &str, group: &str) -> ModelChoice {
        ModelChoice {
            id: id.to_string(),
            group: group.to_string(),
            detail: String::new(),
            ready: true,
        }
    }

    /// The frame minus its filter/status line — what the *rows* say, which is
    /// the only place the provider prefix is supposed to be gone.
    fn without_footer(frame: &str) -> String {
        frame
            .lines()
            .filter(|l| !l.contains("filter:"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn render_to_string(widget: ModelPickerWidget<'_>, width: u16, height: u16) -> String {
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        widget.render(area, &mut buf);
        (0..height)
            .map(|y| {
                (0..width)
                    .map(|x| buf[(x, y)].symbol().to_string())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn marks_the_active_model_and_groups_by_provider() {
        let theme = Theme::dark();
        let local = choice("ollama/llama3.2", "Local (Ollama)");
        let remote = choice("anthropic/claude-opus-4-5", "anthropic");
        let matches = [&local, &remote];
        let out = render_to_string(
            ModelPickerWidget {
                theme: &theme,
                matches: &matches,
                query: "",
                cursor: 0,
                loading: false,
                current: "anthropic/claude-opus-4-5",
            },
            90,
            MODEL_PICKER_HEIGHT,
        );
        assert!(
            out.contains("Local (Ollama)"),
            "group heading missing:\n{out}"
        );
        assert!(
            out.contains("anthropic"),
            "provider heading missing:\n{out}"
        );
        assert!(
            out.contains("claude-opus-4-5 (current)"),
            "the active model must be marked:\n{out}"
        );
        assert!(out.contains("2 models"), "count missing:\n{out}");
    }

    /// The heading names the provider, so the row must not repeat it — the
    /// shape that made NVIDIA's own models render `nvidia/nvidia/…`.
    #[test]
    fn rows_drop_the_provider_the_heading_already_names() {
        let theme = Theme::dark();
        let local = choice("ollama/gemma4:e4b-it-qat", "Local (Ollama)");
        let own = choice("nvidia/nvidia/nemotron-3-super-120b-a12b", "nvidia");
        let vendor = choice("nvidia/mistralai/mistral-large-2-instruct", "nvidia");
        let matches = [&local, &own, &vendor];
        let out = render_to_string(
            ModelPickerWidget {
                theme: &theme,
                matches: &matches,
                query: "",
                cursor: 0,
                loading: false,
                current: "",
            },
            90,
            MODEL_PICKER_HEIGHT,
        );
        // Rows only: the footer carries the highlighted id in full by design.
        let rows = without_footer(&out);
        assert!(
            !rows.contains("nvidia/nvidia/"),
            "the stutter is back:\n{out}"
        );
        assert!(
            !rows.contains("ollama/gemma4"),
            "local rows repeat too:\n{out}"
        );
        assert!(out.contains("gemma4:e4b-it-qat"), "{out}");
        // The vendor namespace is part of the upstream id and stays put.
        assert!(out.contains("mistralai/mistral-large-2-instruct"), "{out}");
        assert!(out.contains("nvidia/nemotron-3-super-120b-a12b"), "{out}");
    }

    /// Eliding is only safe when the heading really does name the prefix;
    /// otherwise the row would hide a provider the user cannot see anywhere.
    #[test]
    fn a_prefix_the_heading_does_not_name_is_kept() {
        let mismatched = choice("openrouter/z-ai/glm-5.2", "nvidia");
        assert_eq!(display_id(&mismatched), "openrouter/z-ai/glm-5.2");
        let bare = choice("llama3.2", "Local (Ollama)");
        assert_eq!(display_id(&bare), "llama3.2");
        let local = choice("ollama/llama3.2", "Local (Ollama)");
        assert_eq!(display_id(&local), "llama3.2");
    }

    /// The footer is where the full `provider/vendor/model` string lives now,
    /// so what to type into `--model` is never more than a glance away.
    #[test]
    fn the_footer_spells_out_the_highlighted_id_in_full() {
        let theme = Theme::dark();
        let own = choice("nvidia/nvidia/nemotron-3-super-120b-a12b", "nvidia");
        let matches = [&own];
        for width in [90u16, 44] {
            let out = render_to_string(
                ModelPickerWidget {
                    theme: &theme,
                    matches: &matches,
                    query: "",
                    cursor: 0,
                    loading: false,
                    current: "",
                },
                width,
                MODEL_PICKER_HEIGHT,
            );
            // Rows elide the prefix, so a full id on screen can only be the
            // footer's. The narrow pane drops the count to keep it.
            assert!(
                out.contains("nvidia/nvidia/nemotron"),
                "the full id is not on screen at width {width}:\n{out}"
            );
        }
    }

    /// A window scrolled into the middle of a hundred-row provider block must
    /// still name the provider: the rows no longer carry it themselves.
    #[test]
    fn a_scrolled_window_still_names_its_provider() {
        let theme = Theme::dark();
        let owned: Vec<ModelChoice> = (0..40)
            .map(|i| choice(&format!("nvidia/mistralai/model-{i:02}"), "nvidia"))
            .collect();
        let matches: Vec<&ModelChoice> = owned.iter().collect();
        let out = render_to_string(
            ModelPickerWidget {
                theme: &theme,
                matches: &matches,
                query: "",
                cursor: 30,
                loading: false,
                current: "",
            },
            90,
            MODEL_PICKER_HEIGHT,
        );
        assert!(
            out.lines()
                .any(|l| l.trim_matches(|ch| ch == '│' || ch == ' ') == "nvidia"),
            "no heading line names the provider:\n{out}"
        );
        assert!(out.contains("> mistralai/model-30"), "{out}");
    }

    /// A row may never sit under another group's heading. When the boundary
    /// falls on the last visible line there is no room for the new heading,
    /// and drawing the row anyway filed NVIDIA's catalog under `meta` — which
    /// a stripped row has no way to contradict.
    #[test]
    fn no_row_is_filed_under_another_groups_heading() {
        let theme = Theme::dark();
        let mut owned: Vec<ModelChoice> = (0..8)
            .map(|i| choice(&format!("ollama/local-{i}"), "Local (Ollama)"))
            .collect();
        owned.push(choice("nvidia/mistralai/remote-0", "nvidia"));
        let matches: Vec<&ModelChoice> = owned.iter().collect();
        let out = render_to_string(
            ModelPickerWidget {
                theme: &theme,
                matches: &matches,
                query: "",
                cursor: 0,
                loading: false,
                current: "",
            },
            90,
            MODEL_PICKER_HEIGHT,
        );
        let rows = without_footer(&out);
        assert!(
            rows.contains("local-7"),
            "the local block is cut short:\n{out}"
        );
        // The row's own text no longer names its provider, so the heading is
        // the only thing that can: either both are on screen, or neither is.
        assert!(
            !rows.contains("remote-0") || rows.contains("nvidia"),
            "an nvidia row is on screen with no nvidia heading:\n{out}"
        );
    }

    /// A group boundary inside the window costs a line. Sizing the window in
    /// rows alone pushed the highlighted row past the last one, and the
    /// truncate ate it — the picker rendered with no visible cursor.
    #[test]
    fn the_highlighted_row_survives_a_group_boundary() {
        let theme = Theme::dark();
        let mut owned: Vec<ModelChoice> = (0..8)
            .map(|i| choice(&format!("ollama/local-{i}"), "Local (Ollama)"))
            .collect();
        owned.extend((0..8).map(|i| choice(&format!("nvidia/mistralai/remote-{i}"), "nvidia")));
        let matches: Vec<&ModelChoice> = owned.iter().collect();
        for cursor in 0..matches.len() {
            let out = render_to_string(
                ModelPickerWidget {
                    theme: &theme,
                    matches: &matches,
                    query: "",
                    cursor,
                    loading: false,
                    current: "",
                },
                90,
                MODEL_PICKER_HEIGHT,
            );
            // The marker, not the bare id: the footer also carries the id.
            let marked = format!("> {}", display_id(matches[cursor]));
            assert!(
                out.contains(&marked),
                "row {cursor} is not on screen as {marked:?}:\n{out}"
            );
        }
    }

    /// A still-running discovery must not read as "there are no models".
    #[test]
    fn loading_and_empty_are_different_messages() {
        let theme = Theme::dark();
        let loading = render_to_string(
            ModelPickerWidget {
                theme: &theme,
                matches: &[],
                query: "",
                cursor: 0,
                loading: true,
                current: "",
            },
            90,
            MODEL_PICKER_HEIGHT,
        );
        assert!(loading.contains("searching"), "{loading}");

        let empty = render_to_string(
            ModelPickerWidget {
                theme: &theme,
                matches: &[],
                query: "",
                cursor: 0,
                loading: false,
                current: "",
            },
            90,
            MODEL_PICKER_HEIGHT,
        );
        assert!(empty.contains("No models found"), "{empty}");
        assert!(!empty.contains("searching"), "{empty}");
    }

    /// Every drawn line must fit the pane — a model id is long and the detail
    /// column is right-aligned against the border.
    #[test]
    fn rows_never_exceed_the_pane_width() {
        let theme = Theme::dark();
        let long = ModelChoice {
            id: "openrouter/some-vendor/a-very-long-model-identifier-that-runs-on".to_string(),
            group: "openrouter".to_string(),
            detail: "context 200k".to_string(),
            ready: true,
        };
        for width in [30usize, 60, 200] {
            let line = row(&long, true, &long.id, width, &theme);
            let drawn: usize = line.spans.iter().map(|s| s.content.width()).sum();
            assert!(
                drawn <= width,
                "row is {drawn} cells wide, pane is {width}: {:?}",
                line.spans
                    .iter()
                    .map(|s| s.content.as_ref())
                    .collect::<String>()
            );
            // The "you are here" marker survives truncation — it is the one
            // thing the row must never lose.
            assert!(
                line.spans.iter().any(|s| s.content.contains("(current)")),
                "the current-model mark was truncated away at width {width}"
            );
        }
    }
}