1use 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
30pub const MODEL_PICKER_VISIBLE_ROWS: usize = 10;
33
34pub const MODEL_PICKER_HEIGHT: u16 = MODEL_PICKER_VISIBLE_ROWS as u16 + 3;
36
37pub struct ModelPickerWidget<'a> {
38 pub theme: &'a Theme,
39 pub matches: &'a [&'a ModelChoice],
41 pub query: &'a str,
43 pub cursor: usize,
44 pub loading: bool,
46 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 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 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 if lines.len() + 2 > visible {
96 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 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 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
155fn window_start(matches: &[&ModelChoice], cursor: usize, visible: usize) -> usize {
163 let mut start = cursor;
164 let mut cost = 2usize;
166 while start > 0 {
167 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
184fn 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 if group == prefix || group.contains(&format!("({prefix})")) {
205 rest
206 } else {
207 &choice.id
208 }
209}
210
211fn 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 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 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 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 #[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 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 assert!(out.contains("mistralai/mistral-large-2-instruct"), "{out}");
381 assert!(out.contains("nvidia/nemotron-3-super-120b-a12b"), "{out}");
382 }
383
384 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 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}