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