Skip to main content

wisp/components/
model_selector.rs

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    /// Returns the value of the currently focused model entry.
129    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            // 3-level model (first item, no Down needed)
618            (
619                make_reasoning_items(),
620                0,
621                vec![None, Some(Low), Some(Medium), Some(High), None],
622            ),
623            // 4-level model (Anthropic first, Codex second, need 1 Down)
624            (
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; // Move to Codex (4 levels)
715        for _ in 0..4 {
716            send(&mut s, k(KeyCode::Tab)).await; // Low -> Medium -> High -> Xhigh
717        }
718        assert_eq!(s.reasoning_effort, Some(Xhigh));
719
720        send(&mut s, k(KeyCode::Up)).await; // Back to Anthropic (3 levels)
721        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}