1use super::reasoning_bar::reasoning_bar;
2use crate::settings::types::SettingsChange;
3use std::cmp::Ordering;
4use std::collections::HashSet;
5use tui::{
6 Combobox, Component, Event, Frame, Line, MouseEventKind, PickerKey, Searchable, ViewContext,
7 classify_key,
8};
9use utils::ReasoningEffort;
10
11#[derive(Debug, Clone)]
12pub struct ModelEntry {
13 pub value: String,
14 pub name: String,
15 pub reasoning_levels: Vec<ReasoningEffort>,
16 pub supports_image: bool,
17 pub supports_audio: bool,
18}
19
20impl ModelEntry {
21 fn provider_key(&self) -> &str {
22 self.value
23 .split_once(':')
24 .map_or("Other", |(provider, _)| provider)
25 }
26
27 fn provider_label(&self) -> String {
28 if let Some((provider, _)) = self.name.split_once(" / ") {
29 return provider.to_string();
30 }
31
32 let key = self.provider_key();
33 if key.is_empty() {
34 return "Other".to_string();
35 }
36
37 let mut chars = key.chars();
38 let first = chars
39 .next()
40 .map(|c| c.to_uppercase().to_string())
41 .unwrap_or_default();
42 let rest = chars.as_str().to_lowercase();
43 format!("{first}{rest}")
44 }
45
46 fn model_label(&self) -> &str {
47 self.name
48 .split_once(" / ")
49 .map_or(self.name.as_str(), |(_, model)| model)
50 }
51}
52
53impl Searchable for ModelEntry {
54 fn search_text(&self) -> String {
55 format!("{} {}", self.name, self.value)
56 }
57}
58
59fn compare_model_entries(a: &ModelEntry, b: &ModelEntry) -> Ordering {
60 a.provider_key()
61 .cmp(b.provider_key())
62 .then_with(|| a.model_label().cmp(b.model_label()))
63 .then_with(|| a.name.cmp(&b.name))
64 .then_with(|| a.value.cmp(&b.value))
65}
66
67fn capability_tags(supports_image: bool, supports_audio: bool) -> &'static str {
68 match (supports_image, supports_audio) {
69 (true, true) => "img audio",
70 (true, false) => "img",
71 (false, true) => "audio",
72 (false, false) => "",
73 }
74}
75
76const REASONING_EFFORT_CONFIG_ID: &str = "reasoning_effort";
77
78pub struct ModelSelector {
79 combobox: Combobox<ModelEntry>,
80 all_items: Vec<ModelEntry>,
81 selected_models: HashSet<String>,
82 original_models: HashSet<String>,
83 config_id: String,
84 reasoning_effort: Option<ReasoningEffort>,
85 original_reasoning_effort: Option<ReasoningEffort>,
86}
87
88#[derive(Debug)]
89pub enum ModelSelectorMessage {
90 Done(Vec<SettingsChange>),
91}
92
93impl ModelSelector {
94 pub fn new(
95 items: Vec<ModelEntry>,
96 config_id: String,
97 current_selection: Option<&str>,
98 current_reasoning_effort: Option<&str>,
99 ) -> Self {
100 let selected_models: HashSet<String> = current_selection
101 .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
102 .unwrap_or_default();
103
104 let reasoning = current_reasoning_effort.and_then(|s| s.parse().ok());
105
106 let original_models = selected_models.clone();
107 let all_items = items.clone();
108 let mut combobox = Combobox::new(items);
109 combobox.set_match_sort(compare_model_entries);
110 if !selected_models.is_empty() {
111 combobox.select_first_where(|item| selected_models.contains(&item.value));
112 }
113 Self {
114 combobox,
115 all_items,
116 selected_models,
117 original_models,
118 config_id,
119 reasoning_effort: reasoning,
120 original_reasoning_effort: reasoning,
121 }
122 }
123
124 pub fn query(&self) -> &str {
125 self.combobox.query()
126 }
127
128 pub fn focused_value(&self) -> Option<String> {
130 self.combobox.selected().map(|e| e.value.clone())
131 }
132
133 #[cfg(test)]
134 pub fn selected_count(&self) -> usize {
135 self.selected_models.len()
136 }
137
138 fn toggle_focused(&mut self) {
139 if let Some(entry) = self.combobox.selected() {
140 let value = entry.value.clone();
141 if !self.selected_models.remove(&value) {
142 self.selected_models.insert(value);
143 }
144 }
145 }
146
147 fn clamp_reasoning_to_focused(&mut self) {
148 if let Some(effort) = self.reasoning_effort
149 && let Some(entry) = self.combobox.selected()
150 {
151 if entry.reasoning_levels.is_empty() {
152 self.reasoning_effort = None;
153 } else {
154 self.reasoning_effort = Some(effort.clamp_to(&entry.reasoning_levels));
155 }
156 }
157 }
158
159 fn confirm(&self) -> Vec<SettingsChange> {
160 let mut changes = Vec::new();
161 if !self.selected_models.is_empty() && self.selected_models != self.original_models {
162 let joined = self
163 .selected_models
164 .iter()
165 .cloned()
166 .collect::<Vec<_>>()
167 .join(",");
168 changes.push(SettingsChange {
169 config_id: self.config_id.clone(),
170 new_value: joined,
171 });
172 }
173 if self.reasoning_effort != self.original_reasoning_effort {
174 changes.push(SettingsChange {
175 config_id: REASONING_EFFORT_CONFIG_ID.to_string(),
176 new_value: ReasoningEffort::config_str(self.reasoning_effort).to_string(),
177 });
178 }
179 changes
180 }
181}
182
183impl ModelSelector {
184 pub fn update_viewport(&mut self, max_height: usize) {
185 let header_lines = if self.selected_models.is_empty() {
186 2
187 } else {
188 4
189 };
190 let available = max_height.saturating_sub(header_lines);
191
192 let mut max_items = available;
193 for _ in 0..3 {
194 self.combobox.set_max_visible(max_items.max(1));
195 let matches = self.combobox.visible_matches_with_selection();
196 let groups = count_provider_groups(&matches);
197 let interstitial = if groups > 0 {
198 groups + groups.saturating_sub(1)
199 } else {
200 0
201 };
202 let needed = max_items + interstitial;
203 if needed <= available {
204 break;
205 }
206 max_items = available.saturating_sub(interstitial);
207 }
208 self.combobox.set_max_visible(max_items.max(1));
209 }
210}
211
212impl Component for ModelSelector {
213 type Message = ModelSelectorMessage;
214
215 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
216 if let Event::Mouse(mouse) = event {
217 return match mouse.kind {
218 MouseEventKind::ScrollUp => {
219 self.combobox.move_up();
220 self.clamp_reasoning_to_focused();
221 Some(vec![])
222 }
223 MouseEventKind::ScrollDown => {
224 self.combobox.move_down();
225 self.clamp_reasoning_to_focused();
226 Some(vec![])
227 }
228 _ => Some(vec![]),
229 };
230 }
231 let Event::Key(key) = event else {
232 return None;
233 };
234 match classify_key(*key, self.combobox.query().is_empty()) {
235 PickerKey::Escape => {
236 let changes = self.confirm();
237 Some(vec![ModelSelectorMessage::Done(changes)])
238 }
239 PickerKey::MoveUp => {
240 self.combobox.move_up();
241 self.clamp_reasoning_to_focused();
242 Some(vec![])
243 }
244 PickerKey::MoveDown => {
245 self.combobox.move_down();
246 self.clamp_reasoning_to_focused();
247 Some(vec![])
248 }
249 PickerKey::Tab => {
250 if let Some(entry) = self.combobox.selected()
251 && !entry.reasoning_levels.is_empty()
252 {
253 self.reasoning_effort = ReasoningEffort::cycle_within(
254 self.reasoning_effort,
255 &entry.reasoning_levels,
256 );
257 }
258 Some(vec![])
259 }
260 PickerKey::Confirm => {
261 self.toggle_focused();
262 Some(vec![])
263 }
264 PickerKey::Char(c) => {
265 self.combobox.push_query_char(c);
266 Some(vec![])
267 }
268 PickerKey::Backspace => {
269 self.combobox.pop_query_char();
270 Some(vec![])
271 }
272 PickerKey::MoveLeft
273 | PickerKey::MoveRight
274 | PickerKey::BackTab
275 | PickerKey::BackspaceOnEmpty
276 | PickerKey::ControlChar
277 | PickerKey::Other => Some(vec![]),
278 }
279 }
280
281 fn render(&mut self, context: &ViewContext) -> Frame {
282 let mut lines = Vec::new();
283 let header = format!(" Model search: {}", self.combobox.query());
284 lines.push(Line::new(header));
285 lines.push(Line::new(String::new()));
286
287 if !self.selected_models.is_empty() {
288 let names: Vec<&str> = self
289 .all_items
290 .iter()
291 .filter(|item| self.selected_models.contains(&item.value))
292 .map(|item| item.name.as_str())
293 .collect();
294 let selected_text = format!(" Selected: {}", names.join(", "));
295 lines.push(Line::styled(selected_text, context.theme.muted()));
296 lines.push(Line::new(String::new()));
297 }
298
299 let mut item_lines = Vec::new();
300 if self.combobox.is_empty() {
301 item_lines.push(Line::new(" (no matches found)".to_string()));
302 } else {
303 let selected = &self.selected_models;
304 let mut last_provider: Option<&str> = None;
305
306 let items = self.combobox.visible_matches_with_selection();
307
308 for (entry, is_focused) in &items {
309 let provider = entry.provider_key();
310 if last_provider != Some(provider) {
311 if !item_lines.is_empty() {
312 item_lines.push(Line::new(String::new()));
313 }
314 item_lines.push(Line::styled(
315 format!(" {}", entry.provider_label()),
316 context.theme.text_secondary(),
317 ));
318 last_provider = Some(provider);
319 }
320
321 let check = if selected.contains(&entry.value) {
322 "[x] "
323 } else {
324 "[ ] "
325 };
326
327 let label = format!("{check}{}", entry.model_label());
328 if *is_focused {
329 let mut line = Line::with_style(label, context.theme.selected_row_style());
330 let indicator_style = context
331 .theme
332 .selected_row_style_with_fg(context.theme.highlight_fg());
333 if !entry.reasoning_levels.is_empty() {
334 let bar =
335 reasoning_bar(self.reasoning_effort, entry.reasoning_levels.len());
336 line.push_with_style(format!(" {bar}"), indicator_style);
337 }
338 let caps = capability_tags(entry.supports_image, entry.supports_audio);
339 if !caps.is_empty() {
340 line.push_with_style(format!(" {caps}"), indicator_style);
341 }
342 item_lines.push(line);
343 } else {
344 item_lines.push(Line::styled(label, context.theme.text_primary()));
345 }
346 }
347 }
348
349 let max_h = context.size.height as usize;
350 let available_for_items = max_h.saturating_sub(lines.len());
351 item_lines.truncate(available_for_items);
352 lines.extend(item_lines);
353
354 Frame::new(lines)
355 }
356}
357
358fn count_provider_groups(items: &[(&ModelEntry, bool)]) -> usize {
359 let mut count = 0;
360 let mut last_provider: Option<&str> = None;
361 for (entry, _) in items {
362 let provider = entry.provider_key();
363 if last_provider != Some(provider) {
364 count += 1;
365 last_provider = Some(provider);
366 }
367 }
368 count
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use tui::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
375
376 fn entry(value: &str, name: &str, levels: Vec<ReasoningEffort>) -> ModelEntry {
377 ModelEntry {
378 value: value.to_string(),
379 name: name.to_string(),
380 reasoning_levels: levels,
381 supports_image: false,
382 supports_audio: false,
383 }
384 }
385
386 fn make_items() -> Vec<ModelEntry> {
387 vec![
388 entry(
389 "anthropic:claude-sonnet-4-5",
390 "Anthropic / Claude Sonnet 4.5",
391 vec![],
392 ),
393 entry("deepseek:deepseek-chat", "DeepSeek / DeepSeek Chat", vec![]),
394 entry("gemini:gemini-2.5-pro", "Google / Gemini 2.5 Pro", vec![]),
395 ]
396 }
397
398 fn make_selector() -> ModelSelector {
399 ModelSelector::new(make_items(), "model".to_string(), None, None)
400 }
401
402 fn sel(
403 items: Vec<ModelEntry>,
404 selected: Option<&str>,
405 reasoning: Option<&str>,
406 ) -> ModelSelector {
407 ModelSelector::new(items, "model".to_string(), selected, reasoning)
408 }
409
410 async fn send(s: &mut ModelSelector, k: KeyEvent) -> Option<Vec<ModelSelectorMessage>> {
411 s.on_event(&Event::Key(k)).await
412 }
413
414 fn k(code: KeyCode) -> KeyEvent {
415 KeyEvent::new(code, KeyModifiers::NONE)
416 }
417
418 fn assert_confirm_models(changes: &[SettingsChange], expected: &[&str]) {
419 let model_change = changes.iter().find(|c| c.config_id == "model").unwrap();
420 let parts: HashSet<&str> = model_change.new_value.split(',').collect();
421 for val in expected {
422 assert!(parts.contains(val), "expected {val} in {:?}", parts);
423 }
424 assert_eq!(parts.len(), expected.len());
425 }
426
427 use ReasoningEffort::*;
428
429 fn reasoning_3() -> Vec<ReasoningEffort> {
430 vec![Low, Medium, High]
431 }
432
433 fn reasoning_4() -> Vec<ReasoningEffort> {
434 vec![Low, Medium, High, Xhigh]
435 }
436
437 fn make_reasoning_items() -> Vec<ModelEntry> {
438 vec![
439 entry(
440 "anthropic:claude-opus-4-6",
441 "Anthropic / Claude Opus 4.6",
442 reasoning_3(),
443 ),
444 entry("deepseek:deepseek-chat", "DeepSeek / DeepSeek Chat", vec![]),
445 ]
446 }
447
448 fn make_mixed_reasoning_items() -> Vec<ModelEntry> {
449 vec![
450 entry(
451 "codex:gpt-5.4-codex",
452 "Codex / GPT-5.4 Codex",
453 reasoning_4(),
454 ),
455 entry(
456 "anthropic:claude-opus-4-6",
457 "Anthropic / Claude Opus 4.6",
458 reasoning_3(),
459 ),
460 ]
461 }
462
463 fn many_provider_items() -> Vec<ModelEntry> {
464 ["a:m1", "b:m2", "c:m3", "d:m4", "e:m5", "f:m6"]
465 .into_iter()
466 .map(|v| {
467 let (prov, model) = v.split_once(':').unwrap();
468 entry(
469 v,
470 &format!("{} / {}", prov.to_uppercase(), model.to_uppercase()),
471 vec![],
472 )
473 })
474 .collect()
475 }
476
477 #[tokio::test]
478 async fn enter_toggles_focused_model() {
479 let mut s = make_selector();
480 assert_eq!(s.selected_count(), 0);
481 send(&mut s, k(KeyCode::Enter)).await;
482 assert_eq!(s.selected_count(), 1);
483 send(&mut s, k(KeyCode::Enter)).await;
484 assert_eq!(s.selected_count(), 0);
485 }
486
487 #[tokio::test]
488 async fn space_adds_to_search_query_not_selects() {
489 let mut s = make_selector();
490 assert_eq!(s.selected_count(), 0);
491 assert_eq!(s.query(), "");
492
493 send(&mut s, k(KeyCode::Char('K'))).await;
494 send(&mut s, k(KeyCode::Char('i'))).await;
495 send(&mut s, k(KeyCode::Char('m'))).await;
496 send(&mut s, k(KeyCode::Char('i'))).await;
497 send(&mut s, k(KeyCode::Char(' '))).await;
498 send(&mut s, k(KeyCode::Char('2'))).await;
499
500 assert_eq!(s.query(), "Kimi 2");
501 assert_eq!(
502 s.selected_count(),
503 0,
504 "space should not select the focused model"
505 );
506 }
507
508 #[test]
509 fn confirm_returns_empty_when_nothing_changed() {
510 for (items, selected, reasoning) in [
511 (make_items(), None, None),
512 (
513 make_items(),
514 Some("anthropic:claude-sonnet-4-5,deepseek:deepseek-chat"),
515 None,
516 ),
517 (
518 make_reasoning_items(),
519 Some("anthropic:claude-opus-4-6"),
520 Some("high"),
521 ),
522 ] {
523 let s = sel(items, selected, reasoning);
524 assert!(s.confirm().is_empty());
525 }
526 }
527
528 #[tokio::test]
529 async fn confirm_with_one_returns_single_model() {
530 let mut s = make_selector();
531 send(&mut s, k(KeyCode::Enter)).await;
532 let changes = s.confirm();
533 assert_eq!(changes.len(), 1);
534 assert_eq!(changes[0].config_id, "model");
535 assert_eq!(changes[0].new_value, "anthropic:claude-sonnet-4-5");
536 }
537
538 #[tokio::test]
539 async fn confirm_with_two_returns_comma_joined() {
540 let mut s = make_selector();
541 send(&mut s, k(KeyCode::Enter)).await;
542 send(&mut s, k(KeyCode::Down)).await;
543 send(&mut s, k(KeyCode::Enter)).await;
544 assert_confirm_models(
545 &s.confirm(),
546 &["anthropic:claude-sonnet-4-5", "deepseek:deepseek-chat"],
547 );
548 }
549
550 #[test]
551 fn pre_selected_values_from_current_selection() {
552 let s = sel(
553 make_items(),
554 Some("anthropic:claude-sonnet-4-5,deepseek:deepseek-chat"),
555 None,
556 );
557 assert_eq!(s.selected_count(), 2);
558 }
559
560 #[tokio::test]
561 async fn escape_returns_done_action() {
562 let mut s = make_selector();
563 let msgs = send(&mut s, k(KeyCode::Esc)).await.unwrap();
564 match msgs.as_slice() {
565 [ModelSelectorMessage::Done(changes)] => assert!(changes.is_empty()),
566 other => panic!("expected Done([]), got: {other:?}"),
567 }
568 }
569
570 #[tokio::test]
571 async fn escape_with_selections_returns_done_with_change() {
572 let mut s = make_selector();
573 send(&mut s, k(KeyCode::Enter)).await;
574 send(&mut s, k(KeyCode::Down)).await;
575 send(&mut s, k(KeyCode::Enter)).await;
576
577 let msgs = send(&mut s, k(KeyCode::Esc)).await.unwrap();
578 match msgs.as_slice() {
579 [ModelSelectorMessage::Done(changes)] => {
580 assert_confirm_models(
581 changes,
582 &["anthropic:claude-sonnet-4-5", "deepseek:deepseek-chat"],
583 );
584 }
585 other => panic!("expected Done with model change, got: {other:?}"),
586 }
587 }
588
589 #[tokio::test]
590 async fn escape_after_toggle_returns_change() {
591 let mut s = sel(make_items(), Some("anthropic:claude-sonnet-4-5"), None);
592 send(&mut s, k(KeyCode::Down)).await;
593 send(&mut s, k(KeyCode::Enter)).await;
594 assert_confirm_models(
595 &s.confirm(),
596 &["anthropic:claude-sonnet-4-5", "deepseek:deepseek-chat"],
597 );
598 }
599
600 #[test]
601 fn reasoning_cycle_within_wraps() {
602 let levels = &[Low, Medium, High];
603 let expected = [
604 (None, Some(Low)),
605 (Some(Low), Some(Medium)),
606 (Some(Medium), Some(High)),
607 (Some(High), None),
608 ];
609 for (input, output) in expected {
610 assert_eq!(ReasoningEffort::cycle_within(input, levels), output);
611 }
612 }
613
614 #[tokio::test]
615 async fn tab_cycles_reasoning_levels() {
616 let cases: Vec<(Vec<ModelEntry>, usize, Vec<Option<ReasoningEffort>>)> = vec![
617 (
619 make_reasoning_items(),
620 0,
621 vec![None, Some(Low), Some(Medium), Some(High), None],
622 ),
623 (
625 make_mixed_reasoning_items(),
626 1,
627 vec![None, Some(Low), Some(Medium), Some(High), Some(Xhigh), None],
628 ),
629 ];
630 for (items, downs, expected_sequence) in cases {
631 let mut s = sel(items, None, None);
632 for _ in 0..downs {
633 send(&mut s, k(KeyCode::Down)).await;
634 }
635 assert_eq!(s.reasoning_effort, expected_sequence[0]);
636 for expected in &expected_sequence[1..] {
637 send(&mut s, k(KeyCode::Tab)).await;
638 assert_eq!(s.reasoning_effort, *expected);
639 }
640 }
641 }
642
643 #[tokio::test]
644 async fn tab_on_non_reasoning_model_is_noop() {
645 let mut s = sel(make_reasoning_items(), None, None);
646 send(&mut s, k(KeyCode::Down)).await;
647 assert!(s.combobox.selected().unwrap().reasoning_levels.is_empty());
648 send(&mut s, k(KeyCode::Tab)).await;
649 assert_eq!(s.reasoning_effort, None);
650 }
651
652 #[tokio::test]
653 async fn confirm_returns_both_model_and_reasoning_changes() {
654 let mut s = sel(make_reasoning_items(), None, None);
655 send(&mut s, k(KeyCode::Enter)).await;
656 send(&mut s, k(KeyCode::Tab)).await;
657
658 let changes = s.confirm();
659 assert_eq!(changes.len(), 2, "expected model + reasoning changes");
660 assert!(changes.iter().any(|c| c.config_id == "model"));
661 assert!(
662 changes
663 .iter()
664 .any(|c| c.config_id == "reasoning_effort" && c.new_value == "low")
665 );
666 }
667
668 #[tokio::test]
669 async fn confirm_returns_only_reasoning_when_only_reasoning_changed() {
670 let mut s = sel(
671 make_reasoning_items(),
672 Some("anthropic:claude-opus-4-6"),
673 None,
674 );
675 send(&mut s, k(KeyCode::Tab)).await;
676 send(&mut s, k(KeyCode::Tab)).await;
677
678 let changes = s.confirm();
679 assert_eq!(changes.len(), 1);
680 assert_eq!(changes[0].config_id, "reasoning_effort");
681 assert_eq!(changes[0].new_value, "medium");
682 }
683
684 #[tokio::test]
685 async fn mouse_scroll_moves_selection() {
686 let mut s = make_selector();
687 let first = s.combobox.selected().unwrap().value.clone();
688
689 let mouse = |kind| {
690 Event::Mouse(MouseEvent {
691 kind,
692 column: 0,
693 row: 0,
694 modifiers: KeyModifiers::NONE,
695 })
696 };
697
698 let outcome = s.on_event(&mouse(MouseEventKind::ScrollDown)).await;
699 assert!(outcome.is_some(), "mouse scroll should be consumed");
700 let second = s.combobox.selected().unwrap().value.clone();
701 assert_ne!(
702 first, second,
703 "scroll down should move to a different model"
704 );
705
706 s.on_event(&mouse(MouseEventKind::ScrollUp)).await;
707 let back = s.combobox.selected().unwrap().value.clone();
708 assert_eq!(first, back, "scroll up should return to the original model");
709 }
710
711 #[tokio::test]
712 async fn moving_to_fewer_levels_clamps_xhigh_to_high() {
713 let mut s = sel(make_mixed_reasoning_items(), None, None);
714 send(&mut s, k(KeyCode::Down)).await; for _ in 0..4 {
716 send(&mut s, k(KeyCode::Tab)).await; }
718 assert_eq!(s.reasoning_effort, Some(Xhigh));
719
720 send(&mut s, k(KeyCode::Up)).await; assert_eq!(
722 s.reasoning_effort,
723 Some(High),
724 "xhigh should clamp to high on a 3-level model"
725 );
726 }
727
728 #[tokio::test]
729 async fn focused_item_always_visible_after_scroll() {
730 let mut s = sel(many_provider_items(), None, None);
731 s.update_viewport(10);
732
733 let ctx = ViewContext::new((80, 10));
734 let highlight_bg = ctx.theme.highlight_bg();
735
736 for _ in 0..6 {
737 send(&mut s, k(KeyCode::Down)).await;
738 let frame = s.render(&ctx);
739 let lines = frame.lines();
740 assert!(
741 lines.iter().any(|l| l
742 .spans()
743 .iter()
744 .any(|span| span.style().bg == Some(highlight_bg))),
745 "focused item must be visible after scrolling down, got: {:?}",
746 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
747 );
748 }
749 }
750
751 #[test]
752 fn capability_tags_empty_when_no_support() {
753 assert_eq!(capability_tags(false, false), "");
754 }
755
756 #[test]
757 fn capability_tags_image_only() {
758 assert_eq!(capability_tags(true, false), "img");
759 }
760
761 #[test]
762 fn capability_tags_audio_only() {
763 assert_eq!(capability_tags(false, true), "audio");
764 }
765
766 #[test]
767 fn capability_tags_both() {
768 assert_eq!(capability_tags(true, true), "img audio");
769 }
770
771 #[test]
772 fn focused_row_shows_capability_indicators() {
773 let items = vec![ModelEntry {
774 value: "anthropic:claude-sonnet-4-5".to_string(),
775 name: "Anthropic / Claude Sonnet 4.5".to_string(),
776 reasoning_levels: vec![],
777 supports_image: true,
778 supports_audio: true,
779 }];
780 let mut s = sel(items, None, None);
781 let ctx = ViewContext::new((80, 10));
782 let frame = s.render(&ctx);
783 let text: String = frame.lines().iter().map(|l| l.plain_text()).collect();
784 assert!(
785 text.contains("img"),
786 "focused row should show img indicator"
787 );
788 assert!(
789 text.contains("audio"),
790 "focused row should show audio indicator"
791 );
792 }
793
794 #[test]
795 fn unfocused_row_hides_capability_indicators() {
796 let items = vec![
797 entry("a:m1", "A / M1", vec![]),
798 ModelEntry {
799 value: "b:m2".to_string(),
800 name: "B / M2".to_string(),
801 reasoning_levels: vec![],
802 supports_image: true,
803 supports_audio: true,
804 },
805 ];
806 let mut s = sel(items, None, None);
807 let ctx = ViewContext::new((80, 10));
808 let frame = s.render(&ctx);
809 for line in frame.lines() {
810 let text = line.plain_text();
811 if text.contains("M2") {
812 assert!(!text.contains("img"), "unfocused row should not show img");
813 assert!(
814 !text.contains("audio"),
815 "unfocused row should not show audio"
816 );
817 }
818 }
819 }
820}