Skip to main content

gpui_cn/composer/
model_picker.rs

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
28/// The names of the six default effort levels, from the lowest to the
29/// highest.
30///
31/// A model that supports effort runs at one of them. The effort menu lists
32/// them in this order, unless
33/// [`ModelPickerState::with_effort_levels`] sets others.
34pub const EFFORT_LABELS: [&str; 6] = ["Low", "Medium", "High", "Extra high", "Ultra", "Max"];
35
36/// The effort a model starts at when it is first chosen: `Medium`.
37const DEFAULT_EFFORT: u8 = 1;
38
39/// How many rows get a number shortcut: Cmd+1 to Cmd+9.
40const SHORTCUT_ROWS: usize = 9;
41
42/// The key context of the panel, which takes Up, Down, Enter, Home, and End
43/// the search field passes on.
44const CONTEXT: &str = "GpuiCnModelPicker";
45
46actions!(
47    gpui_cn_model_picker,
48    [
49        /// Moves the highlight to the first row.
50        FirstRow,
51        /// Moves the highlight to the last row.
52        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        // The search field binds Home and End to move its caret. A binding
62        // for the field inside the panel is as deep and comes later, so it
63        // runs first, and it hands the key on while a query is typed.
64        KeyBinding::new("home", FirstRow, Some("GpuiCnModelPicker > Input")),
65        KeyBinding::new("end", LastRow, Some("GpuiCnModelPicker > Input")),
66    ]);
67}
68
69/// A model a [`ModelPicker`] offers.
70///
71/// The id comes from the domain and is unique across the whole catalog,
72/// every provider included.
73///
74/// ```
75/// use gpui_cn::ModelEntry;
76///
77/// let model = ModelEntry::new("gpt-5.6-mini", "GPT-5.6 Mini").effort(true);
78/// assert!(model.supports_effort());
79/// assert!(!model.is_legacy());
80/// ```
81#[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    /// A model with a stable id and the name it shows. It has no effort
92    /// setting and is not legacy.
93    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    /// Whether the model has an effort setting. While it is the chosen
103    /// model, the composer shows its effort menu.
104    pub fn effort(mut self, supported: bool) -> Self {
105        self.effort = supported;
106        self
107    }
108
109    /// Whether the model is legacy. A provider's legacy models fold into a
110    /// collapsed "Legacy models" group at the top of its list.
111    pub fn legacy(mut self, legacy: bool) -> Self {
112        self.legacy = legacy;
113        self
114    }
115
116    /// The id.
117    pub fn id(&self) -> &SharedString {
118        &self.id
119    }
120
121    /// The name.
122    pub fn name(&self) -> &SharedString {
123        &self.name
124    }
125
126    /// Whether the model has an effort setting.
127    pub fn supports_effort(&self) -> bool {
128        self.effort
129    }
130
131    /// Whether the model is legacy.
132    pub fn is_legacy(&self) -> bool {
133        self.legacy
134    }
135}
136
137/// A maker of models, one mark in the picker's rail and the models it owns.
138///
139/// The icon is any [`Icon`]: the picker ships no vendor logos, so an
140/// application brings the marks it has the right to show. A mark that the
141/// application embeds comes in through [`Icon::from_bytes`], and one that
142/// its asset source holds comes in through [`Icon::new`]. A bundled
143/// Lucide [`IconName`] works too, and a provider with no icon shows a
144/// generic bot.
145///
146/// ```
147/// use gpui_cn::{Icon, ModelEntry, ModelProvider};
148/// use gpui_kit::assets::IconName;
149///
150/// // The application's own SVG, embedded in its binary.
151/// const MARK: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>"#;
152///
153/// let provider = ModelProvider::new("acme", "Acme")
154///     .icon(Icon::from_bytes(MARK))
155///     .models([ModelEntry::new("acme-1", "Acme One")]);
156/// assert_eq!(provider.models_of().len(), 1);
157///
158/// let generic = ModelProvider::new("other", "Other").icon(IconName::Star);
159/// assert!(generic.models_of().is_empty());
160/// ```
161#[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    /// A provider with a stable id and the name it shows, with a generic
172    /// icon and no models.
173    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    /// The provider's mark.
183    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
184        self.icon = icon.into();
185        self
186    }
187
188    /// The provider's models, in the order shown.
189    pub fn models(mut self, models: impl IntoIterator<Item = ModelEntry>) -> Self {
190        self.models = models.into_iter().collect();
191        self
192    }
193
194    /// The id.
195    pub fn id(&self) -> &SharedString {
196        &self.id
197    }
198
199    /// The name.
200    pub fn name(&self) -> &SharedString {
201        &self.name
202    }
203
204    /// The models.
205    pub fn models_of(&self) -> &[ModelEntry] {
206        &self.models
207    }
208}
209
210/// What the list of a [`ModelPicker`] shows: the favorites, or one
211/// provider's models. A query overrides both and searches every provider.
212#[derive(Clone, Debug, PartialEq, Eq)]
213#[non_exhaustive]
214pub enum ModelView {
215    /// The models the user starred, across providers.
216    Favorites,
217    /// The models of the provider with this id.
218    Provider(SharedString),
219}
220
221/// What a [`ModelPickerState`] reports.
222#[derive(Clone, Debug, PartialEq, Eq)]
223#[non_exhaustive]
224pub enum ModelPickerEvent {
225    /// A model was chosen. The payload is its id.
226    Selected(SharedString),
227    /// The effort level changed, from `0` to `5`.
228    EffortChanged(u8),
229    /// The rail's entry changed. The payload is the new view.
230    ViewChanged(ModelView),
231    /// The panel opened or closed.
232    OpenChanged(bool),
233    /// The legacy group opened or closed.
234    LegacyToggled(bool),
235    /// A model was starred or unstarred. The application keeps the
236    /// favorites, so it saves them on this event.
237    FavoriteChanged {
238        /// The id of the model.
239        model: SharedString,
240        /// Whether it is a favorite now.
241        favorite: bool,
242    },
243}
244
245/// One row of the list: the fold of legacy models, or a model.
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247enum Row {
248    /// The "Legacy models" header, with how many models it folds.
249    Legacy(usize),
250    /// A model by provider and model index, and its shortcut number
251    /// (from `1`) when it is among the first nine models.
252    Model {
253        provider: usize,
254        model: usize,
255        number: Option<usize>,
256        /// Whether the row belongs to the legacy group, so its height
257        /// follows the fold.
258        folded: bool,
259    },
260}
261
262/// The rows the list shows. A query shows every model whose name it
263/// matches, best match first, then every model of a provider whose name it
264/// matches (see [`fuzzy::rank`]). With no
265/// query the favorites view shows the starred models in the order they
266/// were starred, and a provider's view shows its models, its legacy ones
267/// folded under a header at the top and listed only while the fold is
268/// open.
269fn 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
384/// The catalog, the chosen model and its effort, the favorites, the view
385/// the rail shows, the search, and the open panel of a [`ModelPicker`].
386///
387/// Owned by the view that shows the picker, which observes it so a change
388/// renders. The choice and the favorites are kept by model id, so a catalog
389/// that changes under them keeps the ones whose model is still there. The
390/// state does not save anything: the application listens for
391/// [`ModelPickerEvent::FavoriteChanged`] and seeds the favorites again with
392/// [`with_favorites`](Self::with_favorites).
393pub 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
415/// The search field's focus, which holds the keyboard while the panel is
416/// open.
417impl 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    /// A picker over `providers` with nothing chosen and no favorites. The
425    /// rail starts on the first provider.
426    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    /// Chooses the model with `id`, if the catalog has it. The rail moves
481    /// to its provider.
482    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    /// Starts on `view`: the favorites, or a provider the catalog holds.
493    /// A provider it does not hold changes nothing.
494    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    /// Starts the effort at `level`, clamped to the last level.
507    pub fn with_effort(mut self, level: u8) -> Self {
508        self.effort = level.min(self.last_effort());
509        self
510    }
511
512    /// The names of the effort levels, lowest first, in place of
513    /// [`EFFORT_LABELS`]. `EffortChanged` and
514    /// [`effort`](Self::effort) are indices into them. An empty list
515    /// changes nothing. The effort is clamped to the new last level.
516    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    /// The names of the effort levels, lowest first.
529    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    /// Starts with these favorites, in this order. An id the catalog does
538    /// not hold is ignored, and so is a repeat.
539    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    /// The catalog, in order.
560    pub fn providers(&self) -> &[ModelProvider] {
561        &self.providers
562    }
563
564    /// Replaces the catalog. The choice and the favorites stay for models
565    /// still in the catalog and are dropped for the others.
566    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    /// The id of the chosen model.
593    pub fn selected(&self) -> Option<&SharedString> {
594        self.selected.as_ref()
595    }
596
597    /// The chosen model and its provider.
598    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    /// Chooses the model with `id` and emits `Selected`. An unknown id, or
606    /// the model already chosen, changes nothing. The rail moves to the
607    /// model's provider.
608    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    /// The effort level of the chosen model, an index into
627    /// [`effort_levels`](Self::effort_levels) (`0` to `5` by default), or `None`
628    /// when no model is chosen or the chosen model has no effort setting.
629    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    /// The name of the effort level, such as "Medium".
636    pub fn effort_label(&self) -> Option<SharedString> {
637        self.effort()
638            .map(|level| self.effort_levels[usize::from(level)].clone())
639    }
640
641    /// Sets the effort to `level`, clamped to the last level, and emits
642    /// `EffortChanged`.
643    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    /// The favorite models' ids, in the order they were starred.
654    pub fn favorites(&self) -> &[SharedString] {
655        &self.favorites
656    }
657
658    /// Whether the model with `id` is a favorite.
659    pub fn is_favorite(&self, id: &str) -> bool {
660        self.favorites.iter().any(|favorite| favorite == id)
661    }
662
663    /// Stars or unstars the model with `id` and emits `FavoriteChanged`.
664    /// An unknown id, or a model already in that state, changes nothing.
665    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    /// Stars the model if it is not a favorite and unstars it if it is.
692    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    /// What the rail has selected.
699    pub fn view(&self) -> &ModelView {
700        &self.view
701    }
702
703    /// Shows `view` and clears the search so its models show. A provider
704    /// the catalog does not hold changes nothing.
705    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    /// The text of the search.
721    pub fn query(&self, cx: &App) -> SharedString {
722        self.search.read(cx).value()
723    }
724
725    /// Replaces the text of the search.
726    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    /// The models the list shows now, as `(provider, model)` pairs, in
734    /// the order of the list.
735    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    /// Whether the list shows the "Legacy models" header.
754    pub fn has_legacy_group(&self) -> bool {
755        self.rows.iter().any(|row| matches!(row, Row::Legacy(_)))
756    }
757
758    /// Whether the legacy group is open.
759    pub fn is_legacy_open(&self) -> bool {
760        self.legacy_open
761    }
762
763    /// Opens or closes the legacy group. It is closed until it is opened,
764    /// and closes again when the view changes.
765    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    /// Closes the legacy group with no animation, for a change of view or
797    /// a panel that opens or closes.
798    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    /// Drops the legacy rows once the fold has finished closing.
805    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    /// The rows of the legacy group, which the list measures again while
818    /// the fold moves.
819    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    /// The id of the model the keyboard or the pointer is on.
835    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    /// Whether the panel is open.
845    pub fn is_open(&self) -> bool {
846        self.open
847    }
848
849    /// Opens or closes the panel and emits `OpenChanged`. Closing clears
850    /// the search.
851    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    /// Chooses the model on the `number`th row of the list, counting from
867    /// `1` and only models, as Cmd+1 to Cmd+9 do, and closes the panel.
868    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    /// Recomputes the rows from the search text and the rest of the state.
885    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    /// Recomputes the rows and keeps what the user is looking at: the
892    /// highlighted row by identity and the scroll place. Nothing moves when
893    /// the rows did not change.
894    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    /// Numbers the rows again for the legacy group folding away, which
942    /// changes no row but the shortcuts.
943    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    /// Puts the highlight on `row`, from the pointer.
991    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    /// Moves the highlight one row up or down, wrapping at the ends, and
999    /// scrolls it into view.
1000    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    /// Chooses what `row` stands for: a model is chosen and the panel
1027    /// closes, and the legacy header opens or closes its group.
1028    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    /// The menu that lists the effort levels, owned here so the effort menu
1047    /// and the picker's events stay in one place.
1048    pub(super) fn effort_menu_state(&self) -> &Entity<MenuState> {
1049        &self.effort_menu
1050    }
1051
1052    /// Whether the keys that move within a query-less list apply: Home and
1053    /// End belong to the search field while text is typed.
1054    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/// A picker for a model: a trigger pill that shows the chosen one, and a
1064/// panel with a rail, a search field, and the list of models.
1065///
1066/// The rail holds the favorites, a hairline, and one mark per provider; the
1067/// active entry is brightened. The list shows each model on two lines, its name over its provider, with a
1068/// Cmd+1 to Cmd+9 shortcut on the first nine rows and a star that pins the
1069/// model to the favorites. A provider's legacy models fold into a group at
1070/// the top. A search matches model names and provider names across every
1071/// provider, and the rail is dimmed with no active entry while it has text.
1072/// The search takes the keyboard when the panel opens, and a letter or `/`
1073/// typed anywhere in the panel goes to it.
1074///
1075/// Up and Down move the highlight and wrap, Home and End jump, Enter and
1076/// Space choose the highlighted row, and Escape closes the panel. The
1077/// highlight follows the pointer only while it moves.
1078///
1079/// The effort of the chosen model is not in the panel: the
1080/// [`Composer`](crate::Composer) shows an [`EffortMenu`](crate::EffortMenu)
1081/// beside the trigger.
1082///
1083/// The picker works anywhere: [`trigger`](Self::trigger) replaces the
1084/// default pill, so a toolbar, a status bar, or a settings page can open
1085/// it.
1086///
1087/// ```no_run
1088/// use gpui_cn::{ModelEntry, ModelPicker, ModelPickerState, ModelProvider};
1089/// use gpui_kit::{AppContext as _, Context, Window};
1090///
1091/// fn picker(window: &mut Window, cx: &mut Context<()>) -> ModelPicker {
1092///     let state = cx.new(|cx| {
1093///         ModelPickerState::new(
1094///             [ModelProvider::new("codex", "Codex").models([
1095///                 ModelEntry::new("gpt-5.6-mini", "GPT-5.6 Mini").effort(true),
1096///             ])],
1097///             window,
1098///             cx,
1099///         )
1100///         .with_selected("gpt-5.6-mini")
1101///     });
1102///     ModelPicker::new("model", &state)
1103/// }
1104/// ```
1105#[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    /// A picker on `state`, with a stable id.
1118    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    /// Replaces the trigger. It shows as selected while the panel is open.
1130    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    /// Which edge of the trigger the panel lines up with. The default is
1138    /// `Start`, as a popover's.
1139    pub fn align(mut self, align: Align) -> Self {
1140        self.align = align;
1141        self
1142    }
1143
1144    /// Shows the default trigger as the provider's mark alone, with the
1145    /// model's name in a tooltip and no chevron, as a very narrow toolbar
1146    /// does. With no model chosen there is no mark, and the option does
1147    /// nothing.
1148    pub fn icon_only(mut self, icon_only: bool) -> Self {
1149        self.icon_only = icon_only;
1150        self
1151    }
1152
1153    /// What the default trigger needs of a row: its width with the name cut
1154    /// to the minimum, and its width as the provider's mark alone.
1155    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
1175/// Styles the panel.
1176impl 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
1286/// The panel: the rail, the search, and the list.
1287///
1288/// The panel reads its keys in one `on_key_down` handler and not through
1289/// actions: a character typed anywhere in the panel must reach the search
1290/// field, and a number with the secondary key must choose a row, which
1291/// bindings in a context cannot tell apart from text.
1292fn 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    // The list pane keeps the menu's inset on every side, so the fills of
1454    // rows, and the ring of a focused one, stay clear of the rail's
1455    // hairline and the panel's border. A slot holds a row with half the
1456    // gap above and below it, so the pane takes that half off its top and
1457    // bottom, and a larger step under the underline.
1458    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
1583/// What a row is drawn from, owned by the list's row builder, which
1584/// outlives the render that made it.
1585struct Rows {
1586    id: ElementId,
1587    state: Entity<ModelPickerState>,
1588    look: MenuLook,
1589    pointer_cursors: bool,
1590}
1591
1592impl Rows {
1593    /// A row in its slot: the slot is the row's height and the row is a
1594    /// step shorter on both sides, so the fills of two rows never touch.
1595    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
1795/// The text of a row's number shortcut: the command key and the digit on
1796/// macOS, Ctrl and the digit elsewhere.
1797fn 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}