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//!   * **A filter line.** A provider's `/models` endpoint routinely returns
11//!     100+ ids. A fixed list of four would be a lie about what is available,
12//!     and an unfiltered list of two hundred is unusable; typing narrows it.
13
14use ratatui::buffer::Buffer;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Block, Borders, Paragraph, Widget};
19use unicode_width::UnicodeWidthStr;
20
21use crate::domain::ModelChoice;
22use crate::render::theme::Theme;
23
24/// Rows drawn at once. Enough to see a provider's block without swallowing the
25/// transcript; the window scrolls with the cursor beyond that.
26pub const MODEL_PICKER_VISIBLE_ROWS: usize = 10;
27
28/// Total pane height including borders and the filter line.
29pub const MODEL_PICKER_HEIGHT: u16 = MODEL_PICKER_VISIBLE_ROWS as u16 + 3;
30
31pub struct ModelPickerWidget<'a> {
32    pub theme: &'a Theme,
33    /// Rows that survived the filter, in display order.
34    pub matches: &'a [&'a ModelChoice],
35    /// The live filter text.
36    pub query: &'a str,
37    pub cursor: usize,
38    /// Discovery still running — distinguishes "looking" from "none found".
39    pub loading: bool,
40    /// The session's active model, marked so the picker always answers "what am
41    /// I on right now?" without a second command.
42    pub current: &'a str,
43}
44
45impl<'a> Widget for ModelPickerWidget<'a> {
46    fn render(self, area: Rect, buf: &mut Buffer) {
47        let c = &self.theme.colors;
48        let dim = Style::default().fg(c.text_disabled.to_color());
49        let block = Block::default()
50            .borders(Borders::ALL)
51            .title("Select model — ↑↓ navigate · Enter switch · type to filter · Esc cancel")
52            .border_style(Style::default().fg(c.border.to_color()));
53
54        let inner_height = area.height.saturating_sub(2) as usize;
55        // One line goes to the filter/status row at the bottom.
56        let visible = inner_height
57            .saturating_sub(1)
58            .min(MODEL_PICKER_VISIBLE_ROWS);
59
60        let mut lines: Vec<Line<'static>> = Vec::new();
61        if self.matches.is_empty() {
62            lines.push(Line::from(Span::styled(
63                if self.loading {
64                    "  searching for available models…".to_string()
65                } else if self.query.is_empty() {
66                    "  No models found. Pull one with `ollama pull`, or set a provider API key."
67                        .to_string()
68                } else {
69                    format!("  Nothing matches {:?}.", self.query)
70                },
71                dim,
72            )));
73        } else {
74            // Scroll the window to keep the cursor in view.
75            let start = self.cursor.saturating_sub(visible.saturating_sub(1));
76            let width = area.width.saturating_sub(2) as usize;
77            let mut last_group: Option<&str> = None;
78            for (i, choice) in self.matches.iter().enumerate().skip(start).take(visible) {
79                // A heading only when the group changes AND it is not the very
80                // first visible row of a scrolled window (where it would eat a
81                // row to restate context the user just scrolled past).
82                if last_group != Some(choice.group.as_str()) {
83                    last_group = Some(choice.group.as_str());
84                    if i > start || start == 0 {
85                        lines.push(Line::from(Span::styled(
86                            format!(" {}", choice.group),
87                            Style::default()
88                                .fg(c.header.to_color())
89                                .add_modifier(Modifier::BOLD),
90                        )));
91                    }
92                }
93                lines.push(row(
94                    choice,
95                    i == self.cursor,
96                    self.current,
97                    width,
98                    self.theme,
99                ));
100            }
101            lines.truncate(visible);
102        }
103
104        // Filter / status footer.
105        let footer = if self.query.is_empty() {
106            let shown = self.matches.len();
107            if self.loading {
108                " filter: (type to narrow) · still searching…".to_string()
109            } else {
110                format!(" filter: (type to narrow) · {shown} models")
111            }
112        } else {
113            format!(
114                " filter: {} · {} match{}",
115                self.query,
116                self.matches.len(),
117                if self.matches.len() == 1 { "" } else { "es" }
118            )
119        };
120        lines.push(Line::from(Span::styled(footer, dim)));
121
122        Paragraph::new(lines).block(block).render(area, buf);
123    }
124}
125
126/// One model row: cursor, id, a `(current)` tag when it is the active model,
127/// and the dim detail column right-padded to the pane width.
128fn row(
129    choice: &ModelChoice,
130    highlighted: bool,
131    current: &str,
132    width: usize,
133    theme: &Theme,
134) -> Line<'static> {
135    let c = &theme.colors;
136    let prefix = if highlighted { " > " } else { "   " };
137    let id_style = if highlighted {
138        Style::default()
139            .fg(c.brand.to_color())
140            .add_modifier(Modifier::BOLD)
141    } else {
142        Style::default().fg(c.text_primary.to_color())
143    };
144    // Suffixes are fixed-cost and must survive; the id yields to them. An
145    // openrouter id can be 60+ cells on its own, so truncating it is the only
146    // way the row fits — and the marker that says "this is your current model"
147    // is worth more than the tail of a name.
148    // Spelled out rather than a check glyph: Mermaid's output is deliberately
149    // emoji-free (enforced by `.github/scripts/check_no_emoji.py`, which flags
150    // the whole dingbats block), and a word survives truncation legibly anyway.
151    let current_mark = if choice.id == current {
152        " (current)"
153    } else {
154        ""
155    };
156    let pull_mark = if choice.ready { "" } else { " (not pulled)" };
157    let reserved = prefix.width() + current_mark.width() + pull_mark.width();
158    let id = super::truncate_to_cells(&choice.id, width.saturating_sub(reserved));
159
160    let mut spans = vec![
161        Span::styled(prefix, Style::default().fg(c.brand.to_color())),
162        Span::styled(id, id_style),
163    ];
164    if !current_mark.is_empty() {
165        spans.push(Span::styled(
166            current_mark,
167            Style::default().fg(c.success.to_color()),
168        ));
169    }
170    if !pull_mark.is_empty() {
171        spans.push(Span::styled(
172            pull_mark,
173            Style::default().fg(c.warning.to_color()),
174        ));
175    }
176    // The detail column is a nicety: right-align it only when the row has room
177    // left over, and drop it entirely otherwise.
178    if !choice.detail.is_empty() {
179        let used: usize = spans.iter().map(|s| s.content.width()).sum();
180        let detail_width = choice.detail.width();
181        if used + detail_width + 2 <= width {
182            spans.push(Span::raw(" ".repeat(width - used - detail_width - 1)));
183            spans.push(Span::styled(
184                choice.detail.clone(),
185                Style::default().fg(c.text_disabled.to_color()),
186            ));
187        }
188    }
189    Line::from(spans)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn choice(id: &str, group: &str) -> ModelChoice {
197        ModelChoice {
198            id: id.to_string(),
199            group: group.to_string(),
200            detail: String::new(),
201            ready: true,
202        }
203    }
204
205    fn render_to_string(widget: ModelPickerWidget<'_>, width: u16, height: u16) -> String {
206        let area = Rect::new(0, 0, width, height);
207        let mut buf = Buffer::empty(area);
208        widget.render(area, &mut buf);
209        (0..height)
210            .map(|y| {
211                (0..width)
212                    .map(|x| buf[(x, y)].symbol().to_string())
213                    .collect::<String>()
214            })
215            .collect::<Vec<_>>()
216            .join("\n")
217    }
218
219    #[test]
220    fn marks_the_active_model_and_groups_by_provider() {
221        let theme = Theme::dark();
222        let local = choice("ollama/llama3.2", "Local (Ollama)");
223        let remote = choice("anthropic/claude-opus-4-5", "anthropic");
224        let matches = [&local, &remote];
225        let out = render_to_string(
226            ModelPickerWidget {
227                theme: &theme,
228                matches: &matches,
229                query: "",
230                cursor: 0,
231                loading: false,
232                current: "anthropic/claude-opus-4-5",
233            },
234            90,
235            MODEL_PICKER_HEIGHT,
236        );
237        assert!(
238            out.contains("Local (Ollama)"),
239            "group heading missing:\n{out}"
240        );
241        assert!(
242            out.contains("anthropic"),
243            "provider heading missing:\n{out}"
244        );
245        assert!(
246            out.contains("claude-opus-4-5 (current)"),
247            "the active model must be marked:\n{out}"
248        );
249        assert!(out.contains("2 models"), "count missing:\n{out}");
250    }
251
252    /// A still-running discovery must not read as "there are no models".
253    #[test]
254    fn loading_and_empty_are_different_messages() {
255        let theme = Theme::dark();
256        let loading = render_to_string(
257            ModelPickerWidget {
258                theme: &theme,
259                matches: &[],
260                query: "",
261                cursor: 0,
262                loading: true,
263                current: "",
264            },
265            90,
266            MODEL_PICKER_HEIGHT,
267        );
268        assert!(loading.contains("searching"), "{loading}");
269
270        let empty = render_to_string(
271            ModelPickerWidget {
272                theme: &theme,
273                matches: &[],
274                query: "",
275                cursor: 0,
276                loading: false,
277                current: "",
278            },
279            90,
280            MODEL_PICKER_HEIGHT,
281        );
282        assert!(empty.contains("No models found"), "{empty}");
283        assert!(!empty.contains("searching"), "{empty}");
284    }
285
286    /// Every drawn line must fit the pane — a model id is long and the detail
287    /// column is right-aligned against the border.
288    #[test]
289    fn rows_never_exceed_the_pane_width() {
290        let theme = Theme::dark();
291        let long = ModelChoice {
292            id: "openrouter/some-vendor/a-very-long-model-identifier-that-runs-on".to_string(),
293            group: "openrouter".to_string(),
294            detail: "context 200k".to_string(),
295            ready: true,
296        };
297        for width in [30usize, 60, 200] {
298            let line = row(&long, true, &long.id, width, &theme);
299            let drawn: usize = line.spans.iter().map(|s| s.content.width()).sum();
300            assert!(
301                drawn <= width,
302                "row is {drawn} cells wide, pane is {width}: {:?}",
303                line.spans
304                    .iter()
305                    .map(|s| s.content.as_ref())
306                    .collect::<String>()
307            );
308            // The "you are here" marker survives truncation — it is the one
309            // thing the row must never lose.
310            assert!(
311                line.spans.iter().any(|s| s.content.contains("(current)")),
312                "the current-model mark was truncated away at width {width}"
313            );
314        }
315    }
316}