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#[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 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 (make_reasoning_items(), 0, vec![None, Some(Low), Some(Medium), Some(High), None]),
613 (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; for _ in 0..4 {
684 send(&mut s, k(KeyCode::Tab)).await; }
686 assert_eq!(s.reasoning_effort, Some(Xhigh));
687
688 send(&mut s, k(KeyCode::Up)).await; 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}