mermaid_cli/render/widgets/
model_picker.rs1use 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
24pub const MODEL_PICKER_VISIBLE_ROWS: usize = 10;
27
28pub const MODEL_PICKER_HEIGHT: u16 = MODEL_PICKER_VISIBLE_ROWS as u16 + 3;
30
31pub struct ModelPickerWidget<'a> {
32 pub theme: &'a Theme,
33 pub matches: &'a [&'a ModelChoice],
35 pub query: &'a str,
37 pub cursor: usize,
38 pub loading: bool,
40 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 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 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 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 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
126fn 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 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 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 #[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 #[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 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}