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 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 (make_reasoning_items(), 0, vec![None, Some(Low), Some(Medium), Some(High), None]),
602 (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; for _ in 0..4 {
673 send(&mut s, k(KeyCode::Tab)).await; }
675 assert_eq!(s.reasoning_effort, Some(Xhigh));
676
677 send(&mut s, k(KeyCode::Up)).await; 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}