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