1use std::{collections::HashSet, f32::consts::PI, ops::Range};
2
3use gpui_kit::{
4 App, AppContext as _, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle,
5 Focusable as _, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, ListAlignment,
6 ListState, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
7 StyleRefinement, Styled, Subscription, Window, actions,
8 assets::IconName,
9 base::{
10 Align, Selectable, StyledExt as _, TestSupportExt as _,
11 actions::{Confirm, SelectDown, SelectUp},
12 h_flex,
13 input::{InputEvent, InputState},
14 transition, v_flex,
15 },
16 div,
17 prelude::FluentBuilder as _,
18 radians, rems,
19};
20
21use crate::{
22 ActiveTheme as _, Button, ButtonSize, Icon, MenuEvent, MenuState, Popover, ScrollArea, Theme,
23 collapse::{self, Need},
24 fuzzy,
25 menu::{MenuLook, OpenSlot, matched_text, row_frame, search_row, search_style, separator},
26};
27
28pub const EFFORT_LABELS: [&str; 6] = ["Low", "Medium", "High", "Extra high", "Ultra", "Max"];
35
36const DEFAULT_EFFORT: u8 = 1;
38
39const SHORTCUT_ROWS: usize = 9;
41
42const CONTEXT: &str = "GpuiCnModelPicker";
45
46actions!(
47 gpui_cn_model_picker,
48 [
49 FirstRow,
51 LastRow
53 ]
54);
55
56pub(crate) fn init(cx: &mut App) {
57 cx.bind_keys([
58 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
59 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
60 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
61 KeyBinding::new("home", FirstRow, Some("GpuiCnModelPicker > Input")),
65 KeyBinding::new("end", LastRow, Some("GpuiCnModelPicker > Input")),
66 ]);
67}
68
69#[derive(Clone, Debug)]
82#[non_exhaustive]
83pub struct ModelEntry {
84 id: SharedString,
85 name: SharedString,
86 effort: bool,
87 legacy: bool,
88}
89
90impl ModelEntry {
91 pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
94 Self {
95 id: id.into(),
96 name: name.into(),
97 effort: false,
98 legacy: false,
99 }
100 }
101
102 pub fn effort(mut self, supported: bool) -> Self {
105 self.effort = supported;
106 self
107 }
108
109 pub fn legacy(mut self, legacy: bool) -> Self {
112 self.legacy = legacy;
113 self
114 }
115
116 pub fn id(&self) -> &SharedString {
118 &self.id
119 }
120
121 pub fn name(&self) -> &SharedString {
123 &self.name
124 }
125
126 pub fn supports_effort(&self) -> bool {
128 self.effort
129 }
130
131 pub fn is_legacy(&self) -> bool {
133 self.legacy
134 }
135}
136
137#[derive(Clone)]
162#[non_exhaustive]
163pub struct ModelProvider {
164 id: SharedString,
165 name: SharedString,
166 icon: Icon,
167 models: Vec<ModelEntry>,
168}
169
170impl ModelProvider {
171 pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
174 Self {
175 id: id.into(),
176 name: name.into(),
177 icon: IconName::Bot.into(),
178 models: Vec::new(),
179 }
180 }
181
182 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
184 self.icon = icon.into();
185 self
186 }
187
188 pub fn models(mut self, models: impl IntoIterator<Item = ModelEntry>) -> Self {
190 self.models = models.into_iter().collect();
191 self
192 }
193
194 pub fn id(&self) -> &SharedString {
196 &self.id
197 }
198
199 pub fn name(&self) -> &SharedString {
201 &self.name
202 }
203
204 pub fn models_of(&self) -> &[ModelEntry] {
206 &self.models
207 }
208}
209
210#[derive(Clone, Debug, PartialEq, Eq)]
213#[non_exhaustive]
214pub enum ModelView {
215 Favorites,
217 Provider(SharedString),
219}
220
221#[derive(Clone, Debug, PartialEq, Eq)]
223#[non_exhaustive]
224pub enum ModelPickerEvent {
225 Selected(SharedString),
227 EffortChanged(u8),
229 ViewChanged(ModelView),
231 OpenChanged(bool),
233 LegacyToggled(bool),
235 FavoriteChanged {
238 model: SharedString,
240 favorite: bool,
242 },
243}
244
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247enum Row {
248 Legacy(usize),
250 Model {
253 provider: usize,
254 model: usize,
255 number: Option<usize>,
256 folded: bool,
259 },
260}
261
262fn build_rows(
270 providers: &[ModelProvider],
271 view: &ModelView,
272 query: &str,
273 favorites: &[SharedString],
274 legacy_shown: bool,
275 legacy_open: bool,
276) -> Vec<Row> {
277 let query = query.trim();
278 let model = |provider, model| Row::Model {
279 provider,
280 model,
281 number: None,
282 folded: false,
283 };
284 let mut rows = Vec::new();
285 if !query.is_empty() {
286 let all: Vec<(usize, usize, &str)> = providers
287 .iter()
288 .enumerate()
289 .flat_map(|(p, provider)| {
290 provider
291 .models
292 .iter()
293 .enumerate()
294 .map(move |(m, entry)| (p, m, &*entry.name))
295 })
296 .collect();
297 let mut shown = HashSet::new();
298 for found in fuzzy::rank(query, &all, |item| item.2) {
299 let (p, m, _) = all[found.index];
300 shown.insert((p, m));
301 rows.push(model(p, m));
302 }
303 for found in fuzzy::rank(query, providers, |provider| &provider.name) {
304 for m in 0..providers[found.index].models.len() {
305 if shown.insert((found.index, m)) {
306 rows.push(model(found.index, m));
307 }
308 }
309 }
310 } else {
311 match view {
312 ModelView::Favorites => {
313 for id in favorites {
314 if let Some((p, m)) = find_model(providers, id) {
315 rows.push(model(p, m));
316 }
317 }
318 }
319 ModelView::Provider(id) => {
320 if let Some((p, provider)) = providers
321 .iter()
322 .enumerate()
323 .find(|(_, provider)| &provider.id == id)
324 {
325 let legacy = provider.models.iter().filter(|m| m.legacy).count();
326 if legacy > 0 {
327 rows.push(Row::Legacy(legacy));
328 if legacy_shown {
329 rows.extend(
330 provider
331 .models
332 .iter()
333 .enumerate()
334 .filter(|(_, m)| m.legacy)
335 .map(|(m, _)| Row::Model {
336 provider: p,
337 model: m,
338 number: None,
339 folded: true,
340 }),
341 );
342 }
343 }
344 rows.extend(
345 provider
346 .models
347 .iter()
348 .enumerate()
349 .filter(|(_, m)| !m.legacy)
350 .map(|(m, _)| model(p, m)),
351 );
352 }
353 }
354 }
355 }
356 let mut number = 0;
357 for row in &mut rows {
358 if let Row::Model {
359 number: slot,
360 folded,
361 ..
362 } = row
363 {
364 if *folded && !legacy_open {
365 continue;
366 }
367 number += 1;
368 *slot = (number <= SHORTCUT_ROWS).then_some(number);
369 }
370 }
371 rows
372}
373
374fn find_model(providers: &[ModelProvider], id: &str) -> Option<(usize, usize)> {
375 providers.iter().enumerate().find_map(|(p, provider)| {
376 provider
377 .models
378 .iter()
379 .position(|model| model.id == id)
380 .map(|m| (p, m))
381 })
382}
383
384pub struct ModelPickerState {
394 providers: Vec<ModelProvider>,
395 selected: Option<SharedString>,
396 effort: u8,
397 effort_levels: Vec<SharedString>,
398 favorites: Vec<SharedString>,
399 view: ModelView,
400 search: Entity<InputState>,
401 focus: FocusHandle,
402 effort_menu: Entity<MenuState>,
403 open: bool,
404 legacy_open: bool,
405 legacy_shown: bool,
406 legacy_progress: f32,
407 rows: Vec<Row>,
408 highlighted: Option<usize>,
409 list: ListState,
410 _subscriptions: Vec<Subscription>,
411}
412
413impl EventEmitter<ModelPickerEvent> for ModelPickerState {}
414
415impl gpui_kit::Focusable for ModelPickerState {
418 fn focus_handle(&self, cx: &App) -> FocusHandle {
419 self.search.read(cx).focus_handle(cx)
420 }
421}
422
423impl ModelPickerState {
424 pub fn new(
427 providers: impl IntoIterator<Item = ModelProvider>,
428 window: &mut Window,
429 cx: &mut Context<Self>,
430 ) -> Self {
431 let providers: Vec<_> = providers.into_iter().collect();
432 let style = search_style(cx.theme());
433 let search = cx.new(|cx| {
434 let mut input = InputState::new(window, cx).placeholder("Search models...");
435 input.set_editor_style(style);
436 input
437 });
438 let effort_menu = cx.new(MenuState::new);
439 let overdraw = cx.theme().metrics.model_picker_height;
440 let subscriptions = vec![
441 cx.subscribe(&search, |this, _, event: &InputEvent, cx| {
442 if matches!(event, InputEvent::Change) {
443 this.rebuild(cx);
444 }
445 }),
446 cx.subscribe(&effort_menu, |this, _, event: &MenuEvent, cx| {
447 if let MenuEvent::Activated(key) = event
448 && let Ok(level) = key.parse::<u8>()
449 {
450 this.set_effort(level, cx);
451 }
452 }),
453 ];
454 let mut state = Self {
455 view: providers
456 .first()
457 .map(|provider| ModelView::Provider(provider.id.clone()))
458 .unwrap_or(ModelView::Favorites),
459 providers,
460 selected: None,
461 effort: DEFAULT_EFFORT,
462 effort_levels: EFFORT_LABELS.iter().map(|label| (*label).into()).collect(),
463 favorites: Vec::new(),
464 search,
465 focus: cx.focus_handle(),
466 effort_menu,
467 open: false,
468 legacy_open: false,
469 legacy_shown: false,
470 legacy_progress: 0.,
471 rows: Vec::new(),
472 highlighted: None,
473 list: ListState::new(0, ListAlignment::Top, overdraw),
474 _subscriptions: subscriptions,
475 };
476 state.refresh_rows("");
477 state
478 }
479
480 pub fn with_selected(mut self, id: impl Into<SharedString>) -> Self {
483 let id = id.into();
484 if let Some(provider) = self.provider_of(&id) {
485 self.view = ModelView::Provider(provider.id.clone());
486 self.selected = Some(id);
487 self.refresh_rows("");
488 }
489 self
490 }
491
492 pub fn with_view(mut self, view: ModelView) -> Self {
495 let known = match &view {
496 ModelView::Favorites => true,
497 ModelView::Provider(id) => self.providers.iter().any(|provider| &provider.id == id),
498 };
499 if known {
500 self.view = view;
501 self.refresh_rows("");
502 }
503 self
504 }
505
506 pub fn with_effort(mut self, level: u8) -> Self {
508 self.effort = level.min(self.last_effort());
509 self
510 }
511
512 pub fn with_effort_levels(
517 mut self,
518 levels: impl IntoIterator<Item = impl Into<SharedString>>,
519 ) -> Self {
520 let levels: Vec<SharedString> = levels.into_iter().map(Into::into).collect();
521 if !levels.is_empty() {
522 self.effort_levels = levels;
523 self.effort = self.effort.min(self.last_effort());
524 }
525 self
526 }
527
528 pub fn effort_levels(&self) -> &[SharedString] {
530 &self.effort_levels
531 }
532
533 fn last_effort(&self) -> u8 {
534 (self.effort_levels.len() - 1).min(usize::from(u8::MAX)) as u8
535 }
536
537 pub fn with_favorites(
540 mut self,
541 ids: impl IntoIterator<Item = impl Into<SharedString>>,
542 ) -> Self {
543 for id in ids {
544 let id = id.into();
545 if find_model(&self.providers, &id).is_some() && !self.favorites.contains(&id) {
546 self.favorites.push(id);
547 }
548 }
549 self.refresh_rows("");
550 self
551 }
552
553 fn provider_of(&self, model_id: &str) -> Option<&ModelProvider> {
554 self.providers
555 .iter()
556 .find(|provider| provider.models.iter().any(|model| model.id == model_id))
557 }
558
559 pub fn providers(&self) -> &[ModelProvider] {
561 &self.providers
562 }
563
564 pub fn set_providers(
567 &mut self,
568 providers: impl IntoIterator<Item = ModelProvider>,
569 cx: &mut Context<Self>,
570 ) {
571 self.providers = providers.into_iter().collect();
572 if let Some(id) = &self.selected
573 && self.provider_of(id).is_none()
574 {
575 self.selected = None;
576 }
577 let providers = &self.providers;
578 self.favorites
579 .retain(|id| find_model(providers, id).is_some());
580 if let ModelView::Provider(id) = &self.view
581 && !self.providers.iter().any(|provider| &provider.id == id)
582 {
583 self.view = self
584 .providers
585 .first()
586 .map(|provider| ModelView::Provider(provider.id.clone()))
587 .unwrap_or(ModelView::Favorites);
588 }
589 self.rebuild(cx);
590 }
591
592 pub fn selected(&self) -> Option<&SharedString> {
594 self.selected.as_ref()
595 }
596
597 pub fn selected_model(&self) -> Option<(&ModelProvider, &ModelEntry)> {
599 let id = self.selected.as_ref()?;
600 let provider = self.provider_of(id)?;
601 let model = provider.models.iter().find(|model| &model.id == id)?;
602 Some((provider, model))
603 }
604
605 pub fn select(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
609 let id = id.into();
610 if self.selected.as_ref() == Some(&id) {
611 return;
612 }
613 let Some(provider) = self.provider_of(&id) else {
614 return;
615 };
616 let view = ModelView::Provider(provider.id.clone());
617 self.selected = Some(id.clone());
618 if self.view != view && self.view != ModelView::Favorites {
619 self.view = view.clone();
620 cx.emit(ModelPickerEvent::ViewChanged(view));
621 }
622 cx.emit(ModelPickerEvent::Selected(id));
623 self.rebuild(cx);
624 }
625
626 pub fn effort(&self) -> Option<u8> {
630 self.selected_model()
631 .is_some_and(|(_, model)| model.effort)
632 .then_some(self.effort)
633 }
634
635 pub fn effort_label(&self) -> Option<SharedString> {
637 self.effort()
638 .map(|level| self.effort_levels[usize::from(level)].clone())
639 }
640
641 pub fn set_effort(&mut self, level: u8, cx: &mut Context<Self>) {
644 let level = level.min(self.last_effort());
645 if level == self.effort {
646 return;
647 }
648 self.effort = level;
649 cx.emit(ModelPickerEvent::EffortChanged(level));
650 cx.notify();
651 }
652
653 pub fn favorites(&self) -> &[SharedString] {
655 &self.favorites
656 }
657
658 pub fn is_favorite(&self, id: &str) -> bool {
660 self.favorites.iter().any(|favorite| favorite == id)
661 }
662
663 pub fn set_favorite(
666 &mut self,
667 id: impl Into<SharedString>,
668 favorite: bool,
669 cx: &mut Context<Self>,
670 ) {
671 let id = id.into();
672 if find_model(&self.providers, &id).is_none() || self.is_favorite(&id) == favorite {
673 return;
674 }
675 if favorite {
676 self.favorites.push(id.clone());
677 } else {
678 self.favorites.retain(|existing| existing != &id);
679 }
680 cx.emit(ModelPickerEvent::FavoriteChanged {
681 model: id,
682 favorite,
683 });
684 if self.view == ModelView::Favorites {
685 self.rebuild_in_place(cx);
686 } else {
687 cx.notify();
688 }
689 }
690
691 pub fn toggle_favorite(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
693 let id = id.into();
694 let favorite = !self.is_favorite(&id);
695 self.set_favorite(id, favorite, cx);
696 }
697
698 pub fn view(&self) -> &ModelView {
700 &self.view
701 }
702
703 pub fn set_view(&mut self, view: ModelView, window: &mut Window, cx: &mut Context<Self>) {
706 if let ModelView::Provider(id) = &view
707 && !self.providers.iter().any(|provider| &provider.id == id)
708 {
709 return;
710 }
711 self.set_query("", window, cx);
712 if self.view != view {
713 self.view = view.clone();
714 self.fold_legacy_at_once();
715 cx.emit(ModelPickerEvent::ViewChanged(view));
716 }
717 self.rebuild(cx);
718 }
719
720 pub fn query(&self, cx: &App) -> SharedString {
722 self.search.read(cx).value()
723 }
724
725 pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
727 let query = SharedString::from(query.to_string());
728 self.search
729 .update(cx, |search, cx| search.set_value(query, window, cx));
730 self.rebuild(cx);
731 }
732
733 pub fn visible(&self) -> Vec<(&ModelProvider, &ModelEntry)> {
736 self.rows
737 .iter()
738 .filter_map(|row| match row {
739 Row::Model {
740 provider,
741 model,
742 folded,
743 ..
744 } if !folded || self.legacy_open => Some((
745 &self.providers[*provider],
746 &self.providers[*provider].models[*model],
747 )),
748 _ => None,
749 })
750 .collect()
751 }
752
753 pub fn has_legacy_group(&self) -> bool {
755 self.rows.iter().any(|row| matches!(row, Row::Legacy(_)))
756 }
757
758 pub fn is_legacy_open(&self) -> bool {
760 self.legacy_open
761 }
762
763 pub fn set_legacy_open(&mut self, open: bool, cx: &mut Context<Self>) {
766 if self.legacy_open == open {
767 return;
768 }
769 let on_header = self.header_highlighted();
770 self.legacy_open = open;
771 if open {
772 self.legacy_shown = true;
773 self.rebuild_in_place(cx);
774 } else {
775 self.refresh_numbers();
776 cx.notify();
777 }
778 if on_header {
779 self.highlight_header();
780 }
781 cx.emit(ModelPickerEvent::LegacyToggled(open));
782 }
783
784 fn header_highlighted(&self) -> bool {
785 self.highlighted
786 .is_some_and(|row| matches!(self.rows.get(row), Some(Row::Legacy(_))))
787 }
788
789 fn highlight_header(&mut self) {
790 self.highlighted = self
791 .rows
792 .iter()
793 .position(|row| matches!(row, Row::Legacy(_)));
794 }
795
796 fn fold_legacy_at_once(&mut self) {
799 self.legacy_open = false;
800 self.legacy_shown = false;
801 self.legacy_progress = 0.;
802 }
803
804 fn finish_legacy_fold(&mut self, cx: &mut Context<Self>) {
806 if !self.legacy_open && self.legacy_shown {
807 let on_header = self.header_highlighted();
808 self.legacy_shown = false;
809 self.legacy_progress = 0.;
810 self.rebuild_in_place(cx);
811 if on_header {
812 self.highlight_header();
813 }
814 }
815 }
816
817 fn legacy_rows(&self) -> std::ops::Range<usize> {
820 let start = self
821 .rows
822 .iter()
823 .position(|row| matches!(row, Row::Legacy(_)))
824 .map_or(0, |header| header + 1);
825 let len = self
826 .rows
827 .iter()
828 .skip(start)
829 .take_while(|row| matches!(row, Row::Model { folded: true, .. }))
830 .count();
831 start..start + len
832 }
833
834 pub fn highlighted(&self) -> Option<SharedString> {
836 match self.rows.get(self.highlighted?)? {
837 Row::Model {
838 provider, model, ..
839 } => Some(self.providers[*provider].models[*model].id.clone()),
840 Row::Legacy(_) => None,
841 }
842 }
843
844 pub fn is_open(&self) -> bool {
846 self.open
847 }
848
849 pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
852 if self.open == open {
853 return;
854 }
855 self.open = open;
856 self.fold_legacy_at_once();
857 if !open {
858 self.set_query("", window, cx);
859 } else {
860 self.rebuild(cx);
861 }
862 cx.emit(ModelPickerEvent::OpenChanged(open));
863 cx.notify();
864 }
865
866 pub fn select_number(&mut self, number: usize, window: &mut Window, cx: &mut Context<Self>) {
869 let id = self.rows.iter().find_map(|row| match row {
870 Row::Model {
871 provider,
872 model,
873 number: Some(n),
874 ..
875 } if *n == number => Some(self.providers[*provider].models[*model].id.clone()),
876 _ => None,
877 });
878 if let Some(id) = id {
879 self.select(id, cx);
880 self.set_open(false, window, cx);
881 }
882 }
883
884 fn rebuild(&mut self, cx: &mut Context<Self>) {
886 let query = self.query(cx);
887 self.refresh_rows(&query);
888 cx.notify();
889 }
890
891 fn rebuild_in_place(&mut self, cx: &mut Context<Self>) {
895 let query = self.query(cx);
896 let old_rows = self.rows.clone();
897 let top = self.list.logical_scroll_top();
898 let key = self
899 .highlighted
900 .and_then(|row| old_rows.get(row))
901 .map(|row| match row {
902 Row::Legacy(_) => None,
903 Row::Model {
904 provider, model, ..
905 } => Some(self.providers[*provider].models[*model].id.clone()),
906 });
907 let rows = build_rows(
908 &self.providers,
909 &self.view,
910 &query,
911 &self.favorites,
912 self.legacy_shown,
913 self.legacy_open,
914 );
915 if rows != old_rows {
916 self.rows = rows;
917 self.list.reset(self.rows.len());
918 self.list.scroll_to(top);
919 self.highlighted = key.and_then(|key| {
920 self.rows.iter().position(|row| match (row, &key) {
921 (Row::Legacy(_), None) => true,
922 (
923 Row::Model {
924 provider, model, ..
925 },
926 Some(id),
927 ) => &self.providers[*provider].models[*model].id == id,
928 _ => false,
929 })
930 });
931 if self.highlighted.is_none() {
932 self.highlighted = self
933 .rows
934 .iter()
935 .position(|row| matches!(row, Row::Model { .. }));
936 }
937 }
938 cx.notify();
939 }
940
941 fn refresh_numbers(&mut self) {
944 let mut number = 0;
945 for row in &mut self.rows {
946 if let Row::Model {
947 number: slot,
948 folded,
949 ..
950 } = row
951 {
952 if *folded {
953 *slot = None;
954 continue;
955 }
956 number += 1;
957 *slot = (number <= SHORTCUT_ROWS).then_some(number);
958 }
959 }
960 }
961
962 fn refresh_rows(&mut self, query: &str) {
963 self.rows = build_rows(
964 &self.providers,
965 &self.view,
966 query,
967 &self.favorites,
968 self.legacy_shown,
969 self.legacy_open,
970 );
971 self.list.reset(self.rows.len());
972 let chosen = self.selected.as_ref().and_then(|id| {
973 self.rows.iter().position(|row| match row {
974 Row::Model {
975 provider, model, ..
976 } => &self.providers[*provider].models[*model].id == id,
977 Row::Legacy(_) => false,
978 })
979 });
980 self.highlighted = chosen.or_else(|| {
981 self.rows
982 .iter()
983 .position(|row| matches!(row, Row::Model { .. }))
984 });
985 if let Some(row) = self.highlighted {
986 self.list.scroll_to_reveal_item(row);
987 }
988 }
989
990 fn hover_row(&mut self, row: usize, cx: &mut Context<Self>) {
992 if self.highlighted != Some(row) && row < self.rows.len() {
993 self.highlighted = Some(row);
994 cx.notify();
995 }
996 }
997
998 fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
1001 let count = self.rows.len() as isize;
1002 if count == 0 {
1003 return;
1004 }
1005 let row = match self.highlighted {
1006 Some(row) => (row as isize + delta).rem_euclid(count),
1007 None if delta > 0 => 0,
1008 None => count - 1,
1009 } as usize;
1010 self.highlight(row, cx);
1011 }
1012
1013 fn highlight(&mut self, row: usize, cx: &mut Context<Self>) {
1014 self.highlighted = Some(row);
1015 self.list.scroll_to_reveal_item(row);
1016 cx.notify();
1017 }
1018
1019 fn highlight_edge(&mut self, last: bool, cx: &mut Context<Self>) {
1020 if self.rows.is_empty() {
1021 return;
1022 }
1023 self.highlight(if last { self.rows.len() - 1 } else { 0 }, cx);
1024 }
1025
1026 fn choose_row(&mut self, row: usize, window: &mut Window, cx: &mut Context<Self>) {
1029 self.highlighted = Some(row);
1030 match self.rows.get(row).copied() {
1031 Some(Row::Model {
1032 provider, model, ..
1033 }) => {
1034 let id = self.providers[provider].models[model].id.clone();
1035 self.select(id, cx);
1036 self.set_open(false, window, cx);
1037 }
1038 Some(Row::Legacy(_)) => {
1039 let open = !self.legacy_open;
1040 self.set_legacy_open(open, cx);
1041 }
1042 None => {}
1043 }
1044 }
1045
1046 pub(super) fn effort_menu_state(&self) -> &Entity<MenuState> {
1049 &self.effort_menu
1050 }
1051
1052 fn takes_edge_keys(&self, cx: &App) -> bool {
1055 self.query(cx).is_empty()
1056 }
1057}
1058
1059type RailClick = Box<dyn Fn(&mut Window, &mut App)>;
1060
1061type TriggerBuilder = Box<dyn FnOnce(bool, &ModelPickerState) -> gpui_kit::AnyElement>;
1062
1063#[derive(IntoElement)]
1106#[non_exhaustive]
1107pub struct ModelPicker {
1108 id: ElementId,
1109 state: Entity<ModelPickerState>,
1110 trigger: Option<TriggerBuilder>,
1111 align: Align,
1112 icon_only: bool,
1113 style: StyleRefinement,
1114}
1115
1116impl ModelPicker {
1117 pub fn new(id: impl Into<ElementId>, state: &Entity<ModelPickerState>) -> Self {
1119 Self {
1120 id: id.into(),
1121 state: state.clone(),
1122 trigger: None,
1123 align: Align::Start,
1124 icon_only: false,
1125 style: StyleRefinement::default(),
1126 }
1127 }
1128
1129 pub fn trigger(mut self, trigger: impl Selectable + IntoElement + 'static) -> Self {
1131 self.trigger = Some(Box::new(move |open, _| {
1132 trigger.open(open).into_any_element()
1133 }));
1134 self
1135 }
1136
1137 pub fn align(mut self, align: Align) -> Self {
1140 self.align = align;
1141 self
1142 }
1143
1144 pub fn icon_only(mut self, icon_only: bool) -> Self {
1149 self.icon_only = icon_only;
1150 self
1151 }
1152
1153 pub(crate) fn need(&self, window: &Window, cx: &App) -> Need {
1156 let theme = cx.theme();
1157 let padding = theme.metrics.control_padding_sm;
1158 let rem = window.rem_size();
1159 let gap = rems(0.375).to_pixels(rem);
1160 let read = self.state.read(cx);
1161 let label = read
1162 .selected_model()
1163 .map_or(SharedString::from("Select model"), |(_, model)| {
1164 model.name.clone()
1165 });
1166 let label = collapse::text_width(&label, window)
1167 .min(collapse::min_label_width(window, collapse::MIN_LABEL_CHARS));
1168 Need::flexible(
1169 padding * 2. + rems(1.).to_pixels(rem) + gap + label + gap + rems(0.75).to_pixels(rem),
1170 read.selected_model().map(|_| theme.metrics.control_sm),
1171 )
1172 }
1173}
1174
1175impl Styled for ModelPicker {
1177 fn style(&mut self) -> &mut StyleRefinement {
1178 &mut self.style
1179 }
1180}
1181
1182fn child(parent: &ElementId, name: &'static str) -> ElementId {
1183 ElementId::NamedChild(parent.clone().into(), name.into())
1184}
1185
1186impl RenderOnce for ModelPicker {
1187 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1188 let need = self.need(window, cx);
1189 let id = self.id;
1190 let state = self.state;
1191 let (open, search_focus) = {
1192 let read = state.read(cx);
1193 (read.open, read.search.read(cx).focus_handle(cx))
1194 };
1195 let (width, height, text, radius) = {
1196 let theme = cx.theme();
1197 (
1198 theme.metrics.model_picker_width,
1199 theme.metrics.model_picker_height,
1200 theme.text_control,
1201 theme.radius_xl(),
1202 )
1203 };
1204
1205 let trigger = match self.trigger {
1206 Some(build) => build(open, state.read(cx)),
1207 None => {
1208 let read = state.read(cx);
1209 let (label, icon) = read.selected_model().map_or_else(
1210 || ("Select model".into(), None),
1211 |(provider, model)| (model.name.clone(), Some(provider.icon.clone())),
1212 );
1213 let (ink, muted) = {
1214 let theme = cx.theme();
1215 (theme.foreground(), theme.muted_foreground())
1216 };
1217 if self.icon_only
1218 && let Some(icon) = icon.clone()
1219 {
1220 Button::new(child(&id, "trigger"))
1221 .ghost()
1222 .size(ButtonSize::Sm)
1223 .icon(icon)
1224 .accessibility_label(label.clone())
1225 .tooltip(label)
1226 .text_color(muted)
1227 .selected(open)
1228 .into_any_element()
1229 } else {
1230 let keep = collapse::text_width(&label, window)
1231 .min(collapse::min_label_width(window, collapse::MIN_LABEL_CHARS));
1232 let chevron = Icon::from(IconName::ChevronDown)
1233 .size_3()
1234 .when(open, |icon| icon.rotate(radians(PI)));
1235 Button::new(child(&id, "trigger"))
1236 .ghost()
1237 .size(ButtonSize::Sm)
1238 .when_some(icon, |button, icon| button.icon(icon))
1239 .child(
1240 div()
1241 .min_w(keep)
1242 .flex_shrink(1.)
1243 .overflow_hidden()
1244 .whitespace_nowrap()
1245 .text_ellipsis()
1246 .text_color(ink)
1247 .child(label),
1248 )
1249 .flex_shrink(1.)
1250 .min_w(need.min)
1251 .trailing_icon(chevron)
1252 .text_color(muted)
1253 .selected(open)
1254 .into_any_element()
1255 }
1256 }
1257 };
1258 let content = {
1259 let (id, state) = (id.clone(), state.clone());
1260 move |window: &mut Window, cx: &mut App| panel(&id, &state, window, cx)
1261 };
1262 let on_open_change = {
1263 let state = state.clone();
1264 move |open: bool, window: &mut Window, cx: &mut App| {
1265 state.update(cx, |state, cx| state.set_open(open, window, cx));
1266 }
1267 };
1268 Popover::new(id)
1269 .open(open)
1270 .on_open_change(on_open_change)
1271 .align(self.align)
1272 .track_focus(&search_focus)
1273 .trigger(OpenSlot::fixed(trigger, open))
1274 .content(content)
1275 .w(width)
1276 .h(height)
1277 .p_0()
1278 .text_size(text.size)
1279 .line_height(text.line_height)
1280 .rounded(radius)
1281 .overflow_hidden()
1282 .refine_style(&self.style)
1283 }
1284}
1285
1286fn panel(
1293 id: &ElementId,
1294 state: &Entity<ModelPickerState>,
1295 window: &mut Window,
1296 cx: &mut App,
1297) -> gpui_kit::AnyElement {
1298 let (has_group, legacy_open, legacy_shown) = {
1299 let read = state.read(cx);
1300 (read.has_legacy_group(), read.legacy_open, read.legacy_shown)
1301 };
1302 if has_group {
1303 let progress = transition(
1304 child(id, "legacy-fold"),
1305 if legacy_open { 1. } else { 0. },
1306 Theme::global(cx).motion.fold_transition(),
1307 window,
1308 cx,
1309 );
1310 let (moved, range, list) = state.update(cx, |state, _| {
1311 let moved = state.legacy_progress != progress;
1312 state.legacy_progress = progress;
1313 (moved, state.legacy_rows(), state.list.clone())
1314 });
1315 if moved {
1316 list.splice(range.clone(), range.len());
1317 }
1318 if !legacy_open && legacy_shown && progress <= 0. {
1319 let state = state.clone();
1320 window.defer(cx, move |_, cx| {
1321 state.update(cx, |state, cx| state.finish_legacy_fold(cx));
1322 });
1323 }
1324 }
1325 let theme = cx.theme();
1326 let look = MenuLook::of(theme, window.rem_size());
1327 let disabled_opacity = theme.disabled_opacity;
1328 let (rail_width, header_height) = (theme.metrics.model_picker_rail, theme.metrics.model_header);
1329 let row_height = theme.metrics.model_row;
1330 let pointer_cursors = Theme::global(cx).pointer_cursors;
1331 let read = state.read(cx);
1332 let query = read.query(cx);
1333 let searching = !query.trim().is_empty();
1334 let search = read.search.clone();
1335 let search_focus = read.search.read(cx).focus_handle(cx);
1336 let panel_focus = read.focus.clone();
1337 let view = read.view.clone();
1338 let list = read.list.clone();
1339 let empty = read.rows.is_empty();
1340 let empty_text = if view == ModelView::Favorites && !searching {
1341 "Star a model to pin it here"
1342 } else {
1343 "No models found"
1344 };
1345 let providers = read.providers.clone();
1346
1347 let entry = |entry_id: ElementId,
1348 icon: Icon,
1349 label: SharedString,
1350 active: bool,
1351 on_click: RailClick| {
1352 let marked = active && !searching;
1353 div()
1354 .relative()
1355 .w_full()
1356 .py(look.padding / 2.)
1357 .px(look.padding)
1358 .child(
1359 Button::new(entry_id)
1360 .ghost()
1361 .size(ButtonSize::Lg)
1362 .icon(icon)
1363 .accessibility_label(label.clone())
1364 .tooltip(label)
1365 .w_full()
1366 .text_color(if marked {
1367 look.foreground
1368 } else {
1369 look.muted_foreground
1370 })
1371 .selected(marked)
1372 .when(marked, |button| button.bg(look.accent))
1373 .on_click(move |_, window, cx| on_click(window, cx)),
1374 )
1375 .into_any_element()
1376 };
1377 let mut marks = vec![entry(
1378 child(id, "favorites"),
1379 Icon::from(IconName::StarFill),
1380 "Favorites".into(),
1381 view == ModelView::Favorites,
1382 {
1383 let state = state.clone();
1384 Box::new(move |window, cx| {
1385 state.update(cx, |state, cx| {
1386 state.set_view(ModelView::Favorites, window, cx)
1387 });
1388 })
1389 },
1390 )];
1391 marks.push(
1392 div()
1393 .w_full()
1394 .py(look.padding)
1395 .px(look.padding * 2.)
1396 .child(div().h_px().w_full().bg(look.separator))
1397 .into_any_element(),
1398 );
1399 marks.extend(providers.iter().map(|provider| {
1400 let provider_id = provider.id.clone();
1401 entry(
1402 ElementId::NamedChild(child(id, "provider").into(), provider.id.clone()),
1403 provider.icon.clone(),
1404 provider.name.clone(),
1405 view == ModelView::Provider(provider.id.clone()),
1406 {
1407 let state = state.clone();
1408 Box::new(move |window, cx| {
1409 let view = ModelView::Provider(provider_id.clone());
1410 state.update(cx, |state, cx| state.set_view(view, window, cx));
1411 })
1412 },
1413 )
1414 }));
1415 let rail = ScrollArea::new(child(id, "rail"))
1416 .size_full()
1417 .child(div().flex().flex_col().py(look.padding).children(marks));
1418 let rail = div()
1419 .id(child(id, "rail-box"))
1420 .test_support()
1421 .flex_shrink_0()
1422 .w(rail_width)
1423 .h_full()
1424 .border_r_1()
1425 .border_color(look.separator)
1426 .when(searching, |this| this.opacity(disabled_opacity))
1427 .child(rail);
1428
1429 let body = if empty {
1430 div()
1431 .id(child(id, "empty"))
1432 .test_support()
1433 .size_full()
1434 .flex()
1435 .items_center()
1436 .justify_center()
1437 .text_color(look.muted_foreground)
1438 .child(empty_text)
1439 .into_any_element()
1440 } else {
1441 let rows = Rows {
1442 id: id.clone(),
1443 state: state.clone(),
1444 look: look.clone(),
1445 pointer_cursors,
1446 };
1447 ScrollArea::list(child(id, "models"), &list, row_height, move |row, _, cx| {
1448 rows.render(row, cx)
1449 })
1450 .size_full()
1451 .into_any_element()
1452 };
1453 let slot_gap = look.padding / 2.;
1459 let list_box = div()
1460 .id(child(id, "list"))
1461 .test_support()
1462 .flex_1()
1463 .min_h_0()
1464 .w_full()
1465 .px(look.padding)
1466 .pt(look.padding + slot_gap)
1467 .pb(look.padding - slot_gap)
1468 .child(body);
1469
1470 let search_row = search_row(&search, &look, window, cx)
1471 .id(child(id, "search"))
1472 .test_support()
1473 .h(header_height)
1474 .w_full()
1475 .mx(look.padding);
1476 let underline = div()
1477 .id(child(id, "underline"))
1478 .test_support()
1479 .flex_shrink_0()
1480 .px(look.padding)
1481 .child(separator(&look));
1482 let main = v_flex()
1483 .flex_1()
1484 .min_w_0()
1485 .h_full()
1486 .child(div().flex_shrink_0().child(search_row).child(underline))
1487 .child(list_box);
1488
1489 let (up, down, confirm, first, last) = (
1490 state.clone(),
1491 state.clone(),
1492 state.clone(),
1493 state.clone(),
1494 state.clone(),
1495 );
1496 let (key_state, focus_target) = (state.clone(), search_focus.clone());
1497 h_flex()
1498 .id(child(id, "body"))
1499 .test_support()
1500 .size_full()
1501 .items_stretch()
1502 .key_context(CONTEXT)
1503 .track_focus(&panel_focus)
1504 .on_action(move |_: &SelectUp, _, cx| {
1505 up.update(cx, |state, cx| state.move_highlight(-1, cx));
1506 })
1507 .on_action(move |_: &SelectDown, _, cx| {
1508 down.update(cx, |state, cx| state.move_highlight(1, cx));
1509 })
1510 .on_action(move |_: &Confirm, window, cx| {
1511 confirm.update(cx, |state, cx| {
1512 if let Some(row) = state.highlighted {
1513 state.choose_row(row, window, cx);
1514 }
1515 });
1516 })
1517 .on_action(move |_: &FirstRow, _, cx| {
1518 if first.read(cx).takes_edge_keys(cx) {
1519 first.update(cx, |state, cx| state.highlight_edge(false, cx));
1520 } else {
1521 cx.propagate();
1522 }
1523 })
1524 .on_action(move |_: &LastRow, _, cx| {
1525 if last.read(cx).takes_edge_keys(cx) {
1526 last.update(cx, |state, cx| state.highlight_edge(true, cx));
1527 } else {
1528 cx.propagate();
1529 }
1530 })
1531 .on_key_down(move |event: &KeyDownEvent, window, cx| {
1532 let keystroke = &event.keystroke;
1533 if keystroke.modifiers.secondary(cx) {
1534 if let Ok(number) = keystroke.key.parse::<usize>()
1535 && (1..=SHORTCUT_ROWS).contains(&number)
1536 {
1537 cx.stop_propagation();
1538 key_state.update(cx, |state, cx| state.select_number(number, window, cx));
1539 }
1540 return;
1541 }
1542 let in_search = focus_target.contains_focused(window, cx);
1543 if matches!(keystroke.key.as_str(), "left" | "right") && !keystroke.modifiers.modified()
1544 {
1545 let open = keystroke.key == "right";
1546 key_state.update(cx, |state, cx| {
1547 if state.header_highlighted() && state.takes_edge_keys(cx) {
1548 state.set_legacy_open(open, cx);
1549 }
1550 });
1551 return;
1552 }
1553 if keystroke.key == "space" && !keystroke.modifiers.modified() {
1554 let take = key_state.read(cx).takes_edge_keys(cx);
1555 if take {
1556 cx.stop_propagation();
1557 window.prevent_default();
1558 key_state.update(cx, |state, cx| {
1559 if let Some(row) = state.highlighted {
1560 state.choose_row(row, window, cx);
1561 }
1562 });
1563 }
1564 return;
1565 }
1566 if !in_search
1567 && !keystroke.modifiers.modified()
1568 && let Some(text) = keystroke.key_char.as_ref()
1569 && !text.is_empty()
1570 {
1571 cx.stop_propagation();
1572 window.focus(&focus_target, cx);
1573 let search = key_state.read(cx).search.clone();
1574 let typed = format!("{}{text}", search.read(cx).value());
1575 search.update(cx, |search, cx| search.set_value(typed, window, cx));
1576 }
1577 })
1578 .child(rail)
1579 .child(main)
1580 .into_any_element()
1581}
1582
1583struct Rows {
1586 id: ElementId,
1587 state: Entity<ModelPickerState>,
1588 look: MenuLook,
1589 pointer_cursors: bool,
1590}
1591
1592impl Rows {
1593 fn render(&self, index: usize, cx: &mut App) -> gpui_kit::AnyElement {
1596 let row_height = cx.theme().metrics.model_row;
1597 let state = self.state.read(cx);
1598 let folded = matches!(state.rows.get(index), Some(Row::Model { folded: true, .. }));
1599 let progress = if folded { state.legacy_progress } else { 1. };
1600 div()
1601 .w_full()
1602 .h(row_height * progress)
1603 .when(folded, |this| this.overflow_hidden())
1604 .child(
1605 div()
1606 .w_full()
1607 .h(row_height)
1608 .py_0p5()
1609 .opacity(progress)
1610 .child(self.row(index, cx)),
1611 )
1612 .into_any_element()
1613 }
1614
1615 fn row(&self, index: usize, cx: &mut App) -> gpui_kit::AnyElement {
1616 let look = &self.look;
1617 let (row, highlighted, legacy_open) = {
1618 let state = self.state.read(cx);
1619 let Some(row) = state.rows.get(index).copied() else {
1620 return div().into_any_element();
1621 };
1622 (row, state.highlighted == Some(index), state.legacy_open)
1623 };
1624 let touch = look.touch;
1625 let hover_state = self.state.clone();
1626 let click_state = self.state.clone();
1627 let frame = |frame_id: ElementId| {
1628 row_frame(look, highlighted && !touch)
1629 .id(frame_id)
1630 .test_support()
1631 .h_full()
1632 .when(self.pointer_cursors, |this| this.cursor_pointer())
1633 .when(!touch, |this| {
1634 this.on_mouse_move(move |_, _, cx| {
1635 hover_state.update(cx, |state, cx| state.hover_row(index, cx));
1636 })
1637 })
1638 };
1639 let name_text = cx.theme().text_control;
1640 let caption_text = cx.theme().base.typography.xs;
1641 match row {
1642 Row::Legacy(count) => {
1643 let progress = self.state.read(cx).legacy_progress;
1644 frame(child(&self.id, "legacy"))
1645 .aria_expanded(legacy_open)
1646 .child(
1647 v_flex()
1648 .flex_1()
1649 .min_w_0()
1650 .child(
1651 div()
1652 .text_size(name_text.size)
1653 .line_height(name_text.line_height)
1654 .text_color(look.foreground)
1655 .child("Legacy models"),
1656 )
1657 .child(
1658 div()
1659 .text_size(caption_text.size)
1660 .line_height(caption_text.line_height)
1661 .text_color(look.muted_foreground)
1662 .child(crate::plural::counted(count, "model")),
1663 ),
1664 )
1665 .child(
1666 Icon::from(IconName::ChevronDown)
1667 .size_4()
1668 .flex_shrink_0()
1669 .text_color(look.muted_foreground)
1670 .rotate(radians(PI * progress)),
1671 )
1672 .on_click(move |_, window, cx| {
1673 click_state.update(cx, |state, cx| state.choose_row(index, window, cx));
1674 })
1675 .into_any_element()
1676 }
1677 Row::Model {
1678 provider,
1679 model,
1680 number,
1681 ..
1682 } => {
1683 let (provider, model, chosen, favorite, query) = {
1684 let state = self.state.read(cx);
1685 let model = state.providers[provider].models[model].clone();
1686 (
1687 state.providers[provider].clone(),
1688 model.clone(),
1689 state.selected.as_ref() == Some(&model.id),
1690 state.is_favorite(&model.id),
1691 state.query(cx),
1692 )
1693 };
1694 let marks = |text: &SharedString| -> Vec<Range<usize>> {
1695 fuzzy::matched(&query, text).unwrap_or_default()
1696 };
1697 let star_state = self.state.clone();
1698 let star_id = model.id.clone();
1699 frame(ElementId::NamedChild(
1700 self.id.clone().into(),
1701 model.id.clone(),
1702 ))
1703 .child(
1704 v_flex()
1705 .flex_1()
1706 .min_w_0()
1707 .child(
1708 div()
1709 .overflow_hidden()
1710 .whitespace_nowrap()
1711 .text_ellipsis()
1712 .text_size(name_text.size)
1713 .line_height(name_text.line_height)
1714 .text_color(look.foreground)
1715 .child(matched_text(model.name.clone(), &marks(&model.name), look)),
1716 )
1717 .child(
1718 h_flex()
1719 .gap_1p5()
1720 .items_center()
1721 .text_size(caption_text.size)
1722 .line_height(caption_text.line_height)
1723 .text_color(look.muted_foreground)
1724 .child(provider.icon.clone().size_3().flex_shrink_0())
1725 .child(matched_text(
1726 provider.name.clone(),
1727 &marks(&provider.name),
1728 look,
1729 )),
1730 ),
1731 )
1732 .when(chosen, |this| {
1733 this.child(
1734 Icon::from(IconName::Check)
1735 .size_4()
1736 .flex_shrink_0()
1737 .text_color(look.foreground),
1738 )
1739 })
1740 .when_some(number, |this, number| {
1741 this.child(
1742 div()
1743 .id(ElementId::NamedChild(
1744 child(&self.id, "shortcut").into(),
1745 model.id.clone(),
1746 ))
1747 .test_support()
1748 .flex_shrink_0()
1749 .px_1p5()
1750 .rounded(look.row_radius)
1751 .bg(look.separator)
1752 .text_size(caption_text.size)
1753 .line_height(caption_text.line_height)
1754 .text_color(look.muted_foreground)
1755 .child(shortcut_text(number)),
1756 )
1757 })
1758 .child(
1759 Button::new(ElementId::NamedChild(
1760 child(&self.id, "star").into(),
1761 model.id.clone(),
1762 ))
1763 .ghost()
1764 .size(ButtonSize::Default)
1765 .icon(Icon::from(if favorite {
1766 IconName::StarFill
1767 } else {
1768 IconName::Star
1769 }))
1770 .text_color(if favorite {
1771 look.foreground
1772 } else {
1773 look.muted_foreground
1774 })
1775 .accessibility_label(if favorite {
1776 "Remove from favorites"
1777 } else {
1778 "Add to favorites"
1779 })
1780 .on_click(move |_: &ClickEvent, _, cx| {
1781 cx.stop_propagation();
1782 star_state
1783 .update(cx, |state, cx| state.toggle_favorite(star_id.clone(), cx));
1784 }),
1785 )
1786 .on_click(move |_, window, cx| {
1787 click_state.update(cx, |state, cx| state.choose_row(index, window, cx));
1788 })
1789 .into_any_element()
1790 }
1791 }
1792 }
1793}
1794
1795fn shortcut_text(number: usize) -> String {
1798 if cfg!(target_os = "macos") {
1799 format!("\u{2318}{number}")
1800 } else {
1801 format!("Ctrl+{number}")
1802 }
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807 use super::*;
1808
1809 fn catalog() -> Vec<ModelProvider> {
1810 vec![
1811 ModelProvider::new("acme", "Acme").models([
1812 ModelEntry::new("acme-1", "Alpha One"),
1813 ModelEntry::new("acme-old", "Alpha Old").legacy(true),
1814 ModelEntry::new("acme-2", "Alpha Two"),
1815 ]),
1816 ModelProvider::new("zed", "Zed Labs").models([
1817 ModelEntry::new("zed-1", "Beta"),
1818 ModelEntry::new("zed-2", "Alpha Z"),
1819 ]),
1820 ]
1821 }
1822
1823 fn ids(rows: &[Row], providers: &[ModelProvider]) -> Vec<String> {
1824 rows.iter()
1825 .map(|row| match row {
1826 Row::Legacy(count) => format!("legacy:{count}"),
1827 Row::Model {
1828 provider, model, ..
1829 } => providers[*provider].models[*model].id.to_string(),
1830 })
1831 .collect()
1832 }
1833
1834 fn rows(view: ModelView, query: &str, favorites: &[&str], open: bool) -> Vec<String> {
1835 let providers = catalog();
1836 let favorites: Vec<SharedString> = favorites.iter().map(|id| (*id).into()).collect();
1837 ids(
1838 &build_rows(&providers, &view, query, &favorites, open, open),
1839 &providers,
1840 )
1841 }
1842
1843 #[test]
1844 fn a_provider_view_folds_its_legacy_models_under_a_header() {
1845 let acme = || ModelView::Provider("acme".into());
1846 assert_eq!(
1847 rows(acme(), "", &[], false),
1848 ["legacy:1", "acme-1", "acme-2"]
1849 );
1850 assert_eq!(
1851 rows(acme(), "", &[], true),
1852 ["legacy:1", "acme-old", "acme-1", "acme-2"]
1853 );
1854 assert_eq!(
1855 rows(ModelView::Provider("zed".into()), "", &[], true),
1856 ["zed-1", "zed-2"],
1857 "no legacy models, no header"
1858 );
1859 }
1860
1861 #[test]
1862 fn a_query_searches_every_provider_flat_by_model_or_provider_name() {
1863 let acme = || ModelView::Provider("acme".into());
1864 assert_eq!(
1865 rows(acme(), "ALPHA", &[], false),
1866 ["zed-2", "acme-1", "acme-old", "acme-2"],
1867 "best match first, then the models of a matching provider"
1868 );
1869 assert_eq!(rows(acme(), "bta", &[], false), ["zed-1"], "a subsequence");
1870 assert_eq!(rows(acme(), "beta", &[], false), ["zed-1"]);
1871 assert_eq!(
1872 rows(acme(), "zed", &[], false),
1873 ["zed-1", "zed-2"],
1874 "a provider name matches all its models"
1875 );
1876 assert!(rows(acme(), "nope", &[], false).is_empty());
1877 }
1878
1879 #[test]
1880 fn the_favorites_view_lists_the_starred_models_in_the_order_starred() {
1881 assert_eq!(
1882 rows(
1883 ModelView::Favorites,
1884 "",
1885 &["zed-2", "acme-old", "gone"],
1886 false
1887 ),
1888 ["zed-2", "acme-old"]
1889 );
1890 assert!(rows(ModelView::Favorites, "", &[], false).is_empty());
1891 }
1892
1893 #[test]
1894 fn only_the_first_nine_models_get_a_number() {
1895 let providers = vec![
1896 ModelProvider::new("p", "P")
1897 .models((0..12).map(|n| ModelEntry::new(format!("m{n}"), format!("Model {n}")))),
1898 ];
1899 let rows = build_rows(
1900 &providers,
1901 &ModelView::Provider("p".into()),
1902 "",
1903 &[],
1904 false,
1905 false,
1906 );
1907 let numbers: Vec<_> = rows
1908 .iter()
1909 .map(|row| match row {
1910 Row::Model { number, .. } => *number,
1911 Row::Legacy(_) => None,
1912 })
1913 .collect();
1914 assert_eq!(numbers[0], Some(1));
1915 assert_eq!(numbers[8], Some(9));
1916 assert_eq!(numbers[9], None);
1917 }
1918
1919 #[test]
1920 fn the_effort_labels_run_from_low_to_max() {
1921 assert_eq!(EFFORT_LABELS.first(), Some(&"Low"));
1922 assert_eq!(EFFORT_LABELS.last(), Some(&"Max"));
1923 }
1924}