Skip to main content

mermaid_cli/render/widgets/
model_picker.rs

1//! `/model` picker — renders the bottom zone when `UiMode::ModelPicker` is
2//! active.
3//!
4//! Shaped like the `/load` and `/plan config` panes (bordered, arrow-selectable)
5//! with two additions the model list actually needs:
6//!
7//!   * **Group headings.** Local Ollama models and each remote provider are
8//!     visually separated, so "what runs on my machine" is answerable at a
9//!     glance — the distinction a sovereignty-focused tool most owes its user.
10//!     The heading is sticky: a window scrolled into the middle of a hundred-row
11//!     provider block still names that provider on its first line.
12//!   * **Rows without the provider prefix.** The heading already says `nvidia`,
13//!     so the row says `mistralai/mistral-large-2-instruct` — and NVIDIA's own
14//!     models stop reading `nvidia/nvidia/…`. Nothing is lost: the footer
15//!     spells the highlighted row out as the full id `/model` takes.
16//!   * **A filter line.** A provider's `/models` endpoint routinely returns
17//!     100+ ids. A fixed list of four would be a lie about what is available,
18//!     and an unfiltered list of two hundred is unusable; typing narrows it.
19
20use ratatui::buffer::Buffer;
21use ratatui::layout::Rect;
22use ratatui::style::{Modifier, Style};
23use ratatui::text::{Line, Span};
24use ratatui::widgets::{Block, Borders, Paragraph, Widget};
25use unicode_width::UnicodeWidthStr;
26
27use crate::render::theme::Theme;
28use mermaid_domain::ModelChoice;
29
30/// Rows drawn at once. Enough to see a provider's block without swallowing the
31/// transcript; the window scrolls with the cursor beyond that.
32pub const MODEL_PICKER_VISIBLE_ROWS: usize = 10;
33
34/// Total pane height including borders and the filter line.
35pub const MODEL_PICKER_HEIGHT: u16 = MODEL_PICKER_VISIBLE_ROWS as u16 + 3;
36
37pub struct ModelPickerWidget<'a> {
38    pub theme: &'a Theme,
39    /// Rows that survived the filter, in display order.
40    pub matches: &'a [&'a ModelChoice],
41    /// The live filter text.
42    pub query: &'a str,
43    pub cursor: usize,
44    /// Discovery still running — distinguishes "looking" from "none found".
45    pub loading: bool,
46    /// The session's active model, marked so the picker always answers "what am
47    /// I on right now?" without a second command.
48    pub current: &'a str,
49}
50
51impl<'a> Widget for ModelPickerWidget<'a> {
52    fn render(self, area: Rect, buf: &mut Buffer) {
53        let c = &self.theme.colors;
54        let dim = Style::default().fg(c.text_disabled.to_color());
55        let block = Block::default()
56            .borders(Borders::ALL)
57            .title("Select model — ↑↓ navigate · Enter switch · type to filter · Esc cancel")
58            .border_style(Style::default().fg(c.border.to_color()));
59
60        let inner_height = area.height.saturating_sub(2) as usize;
61        // One line goes to the filter/status row at the bottom.
62        let visible = inner_height
63            .saturating_sub(1)
64            .min(MODEL_PICKER_VISIBLE_ROWS);
65        let width = area.width.saturating_sub(2) as usize;
66        // A cursor past the end would only come from a stale frame; clamp
67        // rather than panic on the index.
68        let cursor = self.cursor.min(self.matches.len().saturating_sub(1));
69
70        let mut lines: Vec<Line<'static>> = Vec::new();
71        if self.matches.is_empty() {
72            lines.push(Line::from(Span::styled(
73                if self.loading {
74                    "  searching for available models…".to_string()
75                } else if self.query.is_empty() {
76                    "  No models found. Pull one with `ollama pull`, or set a provider API key."
77                        .to_string()
78                } else {
79                    format!("  Nothing matches {:?}.", self.query)
80                },
81                dim,
82            )));
83        } else {
84            let start = window_start(self.matches, cursor, visible);
85            let mut last_group: Option<&str> = None;
86            for (i, choice) in self.matches.iter().enumerate().skip(start) {
87                if lines.len() >= visible {
88                    break;
89                }
90                if last_group != Some(choice.group.as_str()) {
91                    last_group = Some(choice.group.as_str());
92                    // A heading on every group change — and on the first
93                    // visible row even mid-group, because the rows no longer
94                    // carry the provider themselves.
95                    if lines.len() + 2 > visible {
96                        // No room for the heading AND its row. Stop rather
97                        // than draw the row under the heading above it: an
98                        // nvidia model tucked under `meta` is a worse lie
99                        // than a blank last line. The one exception is a pane
100                        // so short nothing has been drawn yet, where the
101                        // cursor's own row still has to appear.
102                        if !lines.is_empty() {
103                            break;
104                        }
105                    } else {
106                        lines.push(Line::from(Span::styled(
107                            format!(" {}", choice.group),
108                            Style::default()
109                                .fg(c.header.to_color())
110                                .add_modifier(Modifier::BOLD),
111                        )));
112                    }
113                }
114                lines.push(row(choice, i == cursor, self.current, width, self.theme));
115            }
116        }
117
118        // Filter / status footer.
119        let status = if self.query.is_empty() {
120            let shown = self.matches.len();
121            if self.loading {
122                " filter: (type to narrow) · still searching…".to_string()
123            } else {
124                format!(" filter: (type to narrow) · {shown} models")
125            }
126        } else {
127            format!(
128                " filter: {} · {} match{}",
129                self.query,
130                self.matches.len(),
131                if self.matches.len() == 1 { "" } else { "es" }
132            )
133        };
134        // The highlighted row's id, spelled out in full — the exact string
135        // `/model` and `--model` take, which the rows themselves no longer
136        // show. When the pane is too narrow for both, the id wins: the count
137        // is a nicety, the id is the thing this line exists for.
138        let footer = match self.matches.get(cursor) {
139            Some(choice) => {
140                let both = format!("{status} · {}", choice.id);
141                if both.width() <= width {
142                    both
143                } else {
144                    super::truncate_to_cells(&format!(" {}", choice.id), width)
145                }
146            },
147            None => status,
148        };
149        lines.push(Line::from(Span::styled(footer, dim)));
150
151        Paragraph::new(lines).block(block).render(area, buf);
152    }
153}
154
155/// First row of the scroll window.
156///
157/// Not `cursor + 1 - visible`: headings share the pane with rows, so a group
158/// boundary inside the window costs a line, and row-only arithmetic pushed the
159/// highlighted row past `visible` — where the truncate silently ate it and the
160/// picker showed no cursor at all. Walk up from the cursor instead, paying for
161/// every row and every heading, and stop when the budget runs out.
162fn window_start(matches: &[&ModelChoice], cursor: usize, visible: usize) -> usize {
163    let mut start = cursor;
164    // The cursor's own row, plus the heading that always sits above it.
165    let mut cost = 2usize;
166    while start > 0 {
167        // Extending upward always adds a row. It adds a heading too only when
168        // the row above belongs to another group; within one group the heading
169        // already paid for simply moves up.
170        let extra = if matches[start - 1].group == matches[start].group {
171            1
172        } else {
173            2
174        };
175        if cost + extra > visible {
176            break;
177        }
178        cost += extra;
179        start -= 1;
180    }
181    start
182}
183
184/// Row text: the id minus the provider segment its heading already states, so
185/// `nvidia/mistralai/mistral-large-2-instruct` under the `nvidia` heading reads
186/// `mistralai/mistral-large-2-instruct` and NVIDIA's own models stop stuttering
187/// `nvidia/nvidia/…`.
188///
189/// The vendor namespace stays. It is part of the id NIM, OpenRouter, Together
190/// and DeepInfra actually take, and it is what tells `mistralai/…` apart from
191/// `moonshotai/…`. Only a prefix the heading names is dropped — anything else
192/// renders whole rather than hiding a mismatch the user has no way to see.
193fn display_id(choice: &ModelChoice) -> &str {
194    let Some((prefix, rest)) = choice.id.split_once('/') else {
195        return &choice.id;
196    };
197    if rest.is_empty() {
198        return &choice.id;
199    }
200    let group = choice.group.to_ascii_lowercase();
201    let prefix = prefix.to_ascii_lowercase();
202    // A remote group IS the provider name; the local group spells it out as
203    // `Local (Ollama)`.
204    if group == prefix || group.contains(&format!("({prefix})")) {
205        rest
206    } else {
207        &choice.id
208    }
209}
210
211/// One model row: cursor, id, a `(current)` tag when it is the active model,
212/// and the dim detail column right-padded to the pane width.
213fn row(
214    choice: &ModelChoice,
215    highlighted: bool,
216    current: &str,
217    width: usize,
218    theme: &Theme,
219) -> Line<'static> {
220    let c = &theme.colors;
221    let prefix = if highlighted { " > " } else { "   " };
222    let id_style = if highlighted {
223        Style::default()
224            .fg(c.brand.to_color())
225            .add_modifier(Modifier::BOLD)
226    } else {
227        Style::default().fg(c.text_primary.to_color())
228    };
229    // Suffixes are fixed-cost and must survive; the id yields to them. An
230    // openrouter id can be 60+ cells on its own, so truncating it is the only
231    // way the row fits — and the marker that says "this is your current model"
232    // is worth more than the tail of a name.
233    // Spelled out rather than a check glyph: Mermaid's output is deliberately
234    // emoji-free (enforced by `.github/scripts/check_no_emoji.py`, which flags
235    // the whole dingbats block), and a word survives truncation legibly anyway.
236    let current_mark = if choice.id == current {
237        " (current)"
238    } else {
239        ""
240    };
241    let pull_mark = if choice.ready { "" } else { " (not pulled)" };
242    let reserved = prefix.width() + current_mark.width() + pull_mark.width();
243    let id = super::truncate_to_cells(display_id(choice), width.saturating_sub(reserved));
244
245    let mut spans = vec![
246        Span::styled(prefix, Style::default().fg(c.brand.to_color())),
247        Span::styled(id, id_style),
248    ];
249    if !current_mark.is_empty() {
250        spans.push(Span::styled(
251            current_mark,
252            Style::default().fg(c.success.to_color()),
253        ));
254    }
255    if !pull_mark.is_empty() {
256        spans.push(Span::styled(
257            pull_mark,
258            Style::default().fg(c.warning.to_color()),
259        ));
260    }
261    // The detail column is a nicety: right-align it only when the row has room
262    // left over, and drop it entirely otherwise.
263    if !choice.detail.is_empty() {
264        let used: usize = spans.iter().map(|s| s.content.width()).sum();
265        let detail_width = choice.detail.width();
266        if used + detail_width + 2 <= width {
267            spans.push(Span::raw(" ".repeat(width - used - detail_width - 1)));
268            spans.push(Span::styled(
269                choice.detail.clone(),
270                Style::default().fg(c.text_disabled.to_color()),
271            ));
272        }
273    }
274    Line::from(spans)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn choice(id: &str, group: &str) -> ModelChoice {
282        ModelChoice {
283            id: id.to_string(),
284            group: group.to_string(),
285            detail: String::new(),
286            ready: true,
287        }
288    }
289
290    /// The frame minus its filter/status line — what the *rows* say, which is
291    /// the only place the provider prefix is supposed to be gone.
292    fn without_footer(frame: &str) -> String {
293        frame
294            .lines()
295            .filter(|l| !l.contains("filter:"))
296            .collect::<Vec<_>>()
297            .join("\n")
298    }
299
300    fn render_to_string(widget: ModelPickerWidget<'_>, width: u16, height: u16) -> String {
301        let area = Rect::new(0, 0, width, height);
302        let mut buf = Buffer::empty(area);
303        widget.render(area, &mut buf);
304        (0..height)
305            .map(|y| {
306                (0..width)
307                    .map(|x| buf[(x, y)].symbol().to_string())
308                    .collect::<String>()
309            })
310            .collect::<Vec<_>>()
311            .join("\n")
312    }
313
314    #[test]
315    fn marks_the_active_model_and_groups_by_provider() {
316        let theme = Theme::dark();
317        let local = choice("ollama/llama3.2", "Local (Ollama)");
318        let remote = choice("anthropic/claude-opus-4-5", "anthropic");
319        let matches = [&local, &remote];
320        let out = render_to_string(
321            ModelPickerWidget {
322                theme: &theme,
323                matches: &matches,
324                query: "",
325                cursor: 0,
326                loading: false,
327                current: "anthropic/claude-opus-4-5",
328            },
329            90,
330            MODEL_PICKER_HEIGHT,
331        );
332        assert!(
333            out.contains("Local (Ollama)"),
334            "group heading missing:\n{out}"
335        );
336        assert!(
337            out.contains("anthropic"),
338            "provider heading missing:\n{out}"
339        );
340        assert!(
341            out.contains("claude-opus-4-5 (current)"),
342            "the active model must be marked:\n{out}"
343        );
344        assert!(out.contains("2 models"), "count missing:\n{out}");
345    }
346
347    /// The heading names the provider, so the row must not repeat it — the
348    /// shape that made NVIDIA's own models render `nvidia/nvidia/…`.
349    #[test]
350    fn rows_drop_the_provider_the_heading_already_names() {
351        let theme = Theme::dark();
352        let local = choice("ollama/gemma4:e4b-it-qat", "Local (Ollama)");
353        let own = choice("nvidia/nvidia/nemotron-3-super-120b-a12b", "nvidia");
354        let vendor = choice("nvidia/mistralai/mistral-large-2-instruct", "nvidia");
355        let matches = [&local, &own, &vendor];
356        let out = render_to_string(
357            ModelPickerWidget {
358                theme: &theme,
359                matches: &matches,
360                query: "",
361                cursor: 0,
362                loading: false,
363                current: "",
364            },
365            90,
366            MODEL_PICKER_HEIGHT,
367        );
368        // Rows only: the footer carries the highlighted id in full by design.
369        let rows = without_footer(&out);
370        assert!(
371            !rows.contains("nvidia/nvidia/"),
372            "the stutter is back:\n{out}"
373        );
374        assert!(
375            !rows.contains("ollama/gemma4"),
376            "local rows repeat too:\n{out}"
377        );
378        assert!(out.contains("gemma4:e4b-it-qat"), "{out}");
379        // The vendor namespace is part of the upstream id and stays put.
380        assert!(out.contains("mistralai/mistral-large-2-instruct"), "{out}");
381        assert!(out.contains("nvidia/nemotron-3-super-120b-a12b"), "{out}");
382    }
383
384    /// Eliding is only safe when the heading really does name the prefix;
385    /// otherwise the row would hide a provider the user cannot see anywhere.
386    #[test]
387    fn a_prefix_the_heading_does_not_name_is_kept() {
388        let mismatched = choice("openrouter/z-ai/glm-5.2", "nvidia");
389        assert_eq!(display_id(&mismatched), "openrouter/z-ai/glm-5.2");
390        let bare = choice("llama3.2", "Local (Ollama)");
391        assert_eq!(display_id(&bare), "llama3.2");
392        let local = choice("ollama/llama3.2", "Local (Ollama)");
393        assert_eq!(display_id(&local), "llama3.2");
394    }
395
396    /// The footer is where the full `provider/vendor/model` string lives now,
397    /// so what to type into `--model` is never more than a glance away.
398    #[test]
399    fn the_footer_spells_out_the_highlighted_id_in_full() {
400        let theme = Theme::dark();
401        let own = choice("nvidia/nvidia/nemotron-3-super-120b-a12b", "nvidia");
402        let matches = [&own];
403        for width in [90u16, 44] {
404            let out = render_to_string(
405                ModelPickerWidget {
406                    theme: &theme,
407                    matches: &matches,
408                    query: "",
409                    cursor: 0,
410                    loading: false,
411                    current: "",
412                },
413                width,
414                MODEL_PICKER_HEIGHT,
415            );
416            // Rows elide the prefix, so a full id on screen can only be the
417            // footer's. The narrow pane drops the count to keep it.
418            assert!(
419                out.contains("nvidia/nvidia/nemotron"),
420                "the full id is not on screen at width {width}:\n{out}"
421            );
422        }
423    }
424
425    /// A window scrolled into the middle of a hundred-row provider block must
426    /// still name the provider: the rows no longer carry it themselves.
427    #[test]
428    fn a_scrolled_window_still_names_its_provider() {
429        let theme = Theme::dark();
430        let owned: Vec<ModelChoice> = (0..40)
431            .map(|i| choice(&format!("nvidia/mistralai/model-{i:02}"), "nvidia"))
432            .collect();
433        let matches: Vec<&ModelChoice> = owned.iter().collect();
434        let out = render_to_string(
435            ModelPickerWidget {
436                theme: &theme,
437                matches: &matches,
438                query: "",
439                cursor: 30,
440                loading: false,
441                current: "",
442            },
443            90,
444            MODEL_PICKER_HEIGHT,
445        );
446        assert!(
447            out.lines()
448                .any(|l| l.trim_matches(|ch| ch == '│' || ch == ' ') == "nvidia"),
449            "no heading line names the provider:\n{out}"
450        );
451        assert!(out.contains("> mistralai/model-30"), "{out}");
452    }
453
454    /// A row may never sit under another group's heading. When the boundary
455    /// falls on the last visible line there is no room for the new heading,
456    /// and drawing the row anyway filed NVIDIA's catalog under `meta` — which
457    /// a stripped row has no way to contradict.
458    #[test]
459    fn no_row_is_filed_under_another_groups_heading() {
460        let theme = Theme::dark();
461        let mut owned: Vec<ModelChoice> = (0..8)
462            .map(|i| choice(&format!("ollama/local-{i}"), "Local (Ollama)"))
463            .collect();
464        owned.push(choice("nvidia/mistralai/remote-0", "nvidia"));
465        let matches: Vec<&ModelChoice> = owned.iter().collect();
466        let out = render_to_string(
467            ModelPickerWidget {
468                theme: &theme,
469                matches: &matches,
470                query: "",
471                cursor: 0,
472                loading: false,
473                current: "",
474            },
475            90,
476            MODEL_PICKER_HEIGHT,
477        );
478        let rows = without_footer(&out);
479        assert!(
480            rows.contains("local-7"),
481            "the local block is cut short:\n{out}"
482        );
483        // The row's own text no longer names its provider, so the heading is
484        // the only thing that can: either both are on screen, or neither is.
485        assert!(
486            !rows.contains("remote-0") || rows.contains("nvidia"),
487            "an nvidia row is on screen with no nvidia heading:\n{out}"
488        );
489    }
490
491    /// A group boundary inside the window costs a line. Sizing the window in
492    /// rows alone pushed the highlighted row past the last one, and the
493    /// truncate ate it — the picker rendered with no visible cursor.
494    #[test]
495    fn the_highlighted_row_survives_a_group_boundary() {
496        let theme = Theme::dark();
497        let mut owned: Vec<ModelChoice> = (0..8)
498            .map(|i| choice(&format!("ollama/local-{i}"), "Local (Ollama)"))
499            .collect();
500        owned.extend((0..8).map(|i| choice(&format!("nvidia/mistralai/remote-{i}"), "nvidia")));
501        let matches: Vec<&ModelChoice> = owned.iter().collect();
502        for cursor in 0..matches.len() {
503            let out = render_to_string(
504                ModelPickerWidget {
505                    theme: &theme,
506                    matches: &matches,
507                    query: "",
508                    cursor,
509                    loading: false,
510                    current: "",
511                },
512                90,
513                MODEL_PICKER_HEIGHT,
514            );
515            // The marker, not the bare id: the footer also carries the id.
516            let marked = format!("> {}", display_id(matches[cursor]));
517            assert!(
518                out.contains(&marked),
519                "row {cursor} is not on screen as {marked:?}:\n{out}"
520            );
521        }
522    }
523
524    /// A still-running discovery must not read as "there are no models".
525    #[test]
526    fn loading_and_empty_are_different_messages() {
527        let theme = Theme::dark();
528        let loading = render_to_string(
529            ModelPickerWidget {
530                theme: &theme,
531                matches: &[],
532                query: "",
533                cursor: 0,
534                loading: true,
535                current: "",
536            },
537            90,
538            MODEL_PICKER_HEIGHT,
539        );
540        assert!(loading.contains("searching"), "{loading}");
541
542        let empty = render_to_string(
543            ModelPickerWidget {
544                theme: &theme,
545                matches: &[],
546                query: "",
547                cursor: 0,
548                loading: false,
549                current: "",
550            },
551            90,
552            MODEL_PICKER_HEIGHT,
553        );
554        assert!(empty.contains("No models found"), "{empty}");
555        assert!(!empty.contains("searching"), "{empty}");
556    }
557
558    /// Every drawn line must fit the pane — a model id is long and the detail
559    /// column is right-aligned against the border.
560    #[test]
561    fn rows_never_exceed_the_pane_width() {
562        let theme = Theme::dark();
563        let long = ModelChoice {
564            id: "openrouter/some-vendor/a-very-long-model-identifier-that-runs-on".to_string(),
565            group: "openrouter".to_string(),
566            detail: "context 200k".to_string(),
567            ready: true,
568        };
569        for width in [30usize, 60, 200] {
570            let line = row(&long, true, &long.id, width, &theme);
571            let drawn: usize = line.spans.iter().map(|s| s.content.width()).sum();
572            assert!(
573                drawn <= width,
574                "row is {drawn} cells wide, pane is {width}: {:?}",
575                line.spans
576                    .iter()
577                    .map(|s| s.content.as_ref())
578                    .collect::<String>()
579            );
580            // The "you are here" marker survives truncation — it is the one
581            // thing the row must never lose.
582            assert!(
583                line.spans.iter().any(|s| s.content.contains("(current)")),
584                "the current-model mark was truncated away at width {width}"
585            );
586        }
587    }
588}