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