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