agent_ui: Create components for the model selector (#44993)

Danilo Leal created

This PR introduces a few components for the model selector pickers.
Given we're still maintaining two flavors of it due to one of them being
wired through ACP and the other through the language model registry,
having one source of truth for the UI should help with maintenance
moving forward, considering that despite the internal differences, they
look and behave the same from the standpoint of the UI.

Release Notes:

- N/A

Change summary

crates/agent_ui/src/acp/model_selector.rs           |  86 +-------
crates/agent_ui/src/language_model_selector.rs      |  85 +-------
crates/agent_ui/src/ui.rs                           |   4 
crates/agent_ui/src/ui/model_selector_components.rs | 147 ++++++++++++++
4 files changed, 176 insertions(+), 146 deletions(-)

Detailed changes

crates/agent_ui/src/acp/model_selector.rs 🔗

@@ -12,14 +12,11 @@ use gpui::{
 };
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use ui::{
-    DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem,
-    ListItemSpacing, prelude::*,
-};
+use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, prelude::*};
 use util::ResultExt;
 use zed_actions::agent::OpenSettings;
 
-use crate::ui::HoldForDefault;
+use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
 
 pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
 
@@ -236,39 +233,19 @@ impl PickerDelegate for AcpModelPickerDelegate {
     fn render_match(
         &self,
         ix: usize,
-        selected: bool,
+        is_focused: bool,
         _: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         match self.filtered_entries.get(ix)? {
-            AcpModelPickerEntry::Separator(title) => Some(
-                div()
-                    .px_2()
-                    .pb_1()
-                    .when(ix > 1, |this| {
-                        this.mt_1()
-                            .pt_2()
-                            .border_t_1()
-                            .border_color(cx.theme().colors().border_variant)
-                    })
-                    .child(
-                        Label::new(title)
-                            .size(LabelSize::XSmall)
-                            .color(Color::Muted),
-                    )
-                    .into_any_element(),
-            ),
+            AcpModelPickerEntry::Separator(title) => {
+                Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
+            }
             AcpModelPickerEntry::Model(model_info) => {
                 let is_selected = Some(model_info) == self.selected_model.as_ref();
                 let default_model = self.agent_server.default_model(cx);
                 let is_default = default_model.as_ref() == Some(&model_info.id);
 
-                let model_icon_color = if is_selected {
-                    Color::Accent
-                } else {
-                    Color::Muted
-                };
-
                 Some(
                     div()
                         .id(("model-picker-menu-child", ix))
@@ -284,30 +261,10 @@ impl PickerDelegate for AcpModelPickerDelegate {
                                 }))
                         })
                         .child(
-                            ListItem::new(ix)
-                                .inset(true)
-                                .spacing(ListItemSpacing::Sparse)
-                                .toggle_state(selected)
-                                .child(
-                                    h_flex()
-                                        .w_full()
-                                        .gap_1p5()
-                                        .when_some(model_info.icon, |this, icon| {
-                                            this.child(
-                                                Icon::new(icon)
-                                                    .color(model_icon_color)
-                                                    .size(IconSize::Small)
-                                            )
-                                        })
-                                        .child(Label::new(model_info.name.clone()).truncate()),
-                                )
-                                .end_slot(div().pr_3().when(is_selected, |this| {
-                                    this.child(
-                                        Icon::new(IconName::Check)
-                                            .color(Color::Accent)
-                                            .size(IconSize::Small),
-                                    )
-                                })),
+                            ModelSelectorListItem::new(ix, model_info.name.clone())
+                                .is_focused(is_focused)
+                                .is_selected(is_selected)
+                                .when_some(model_info.icon, |this, icon| this.icon(icon)),
                         )
                         .into_any_element()
                 )
@@ -343,7 +300,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
     fn render_footer(
         &self,
         _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
+        _cx: &mut Context<Picker<Self>>,
     ) -> Option<AnyElement> {
         let focus_handle = self.focus_handle.clone();
 
@@ -351,26 +308,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
             return None;
         }
 
-        Some(
-            h_flex()
-                .w_full()
-                .p_1p5()
-                .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
-                .child(
-                    Button::new("configure", "Configure")
-                        .full_width()
-                        .style(ButtonStyle::Outlined)
-                        .key_binding(
-                            KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
-                                .map(|kb| kb.size(rems_from_px(12.))),
-                        )
-                        .on_click(|_, window, cx| {
-                            window.dispatch_action(OpenSettings.boxed_clone(), cx);
-                        }),
-                )
-                .into_any(),
-        )
+        Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
     }
 }
 

crates/agent_ui/src/language_model_selector.rs 🔗

@@ -11,9 +11,11 @@ use language_model::{
 };
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
+use ui::prelude::*;
 use zed_actions::agent::OpenSettings;
 
+use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
+
 type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
 type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
 
@@ -459,28 +461,14 @@ impl PickerDelegate for LanguageModelPickerDelegate {
     fn render_match(
         &self,
         ix: usize,
-        selected: bool,
+        is_focused: bool,
         _: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         match self.filtered_entries.get(ix)? {
-            LanguageModelPickerEntry::Separator(title) => Some(
-                div()
-                    .px_2()
-                    .pb_1()
-                    .when(ix > 1, |this| {
-                        this.mt_1()
-                            .pt_2()
-                            .border_t_1()
-                            .border_color(cx.theme().colors().border_variant)
-                    })
-                    .child(
-                        Label::new(title)
-                            .size(LabelSize::XSmall)
-                            .color(Color::Muted),
-                    )
-                    .into_any_element(),
-            ),
+            LanguageModelPickerEntry::Separator(title) => {
+                Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
+            }
             LanguageModelPickerEntry::Model(model_info) => {
                 let active_model = (self.get_active_model)(cx);
                 let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
@@ -489,35 +477,11 @@ impl PickerDelegate for LanguageModelPickerDelegate {
                 let is_selected = Some(model_info.model.provider_id()) == active_provider_id
                     && Some(model_info.model.id()) == active_model_id;
 
-                let model_icon_color = if is_selected {
-                    Color::Accent
-                } else {
-                    Color::Muted
-                };
-
                 Some(
-                    ListItem::new(ix)
-                        .inset(true)
-                        .spacing(ListItemSpacing::Sparse)
-                        .toggle_state(selected)
-                        .child(
-                            h_flex()
-                                .w_full()
-                                .gap_1p5()
-                                .child(
-                                    Icon::new(model_info.icon)
-                                        .color(model_icon_color)
-                                        .size(IconSize::Small),
-                                )
-                                .child(Label::new(model_info.model.name().0).truncate()),
-                        )
-                        .end_slot(div().pr_3().when(is_selected, |this| {
-                            this.child(
-                                Icon::new(IconName::Check)
-                                    .color(Color::Accent)
-                                    .size(IconSize::Small),
-                            )
-                        }))
+                    ModelSelectorListItem::new(ix, model_info.model.name().0)
+                        .is_focused(is_focused)
+                        .is_selected(is_selected)
+                        .icon(model_info.icon)
                         .into_any_element(),
                 )
             }
@@ -527,34 +491,15 @@ impl PickerDelegate for LanguageModelPickerDelegate {
     fn render_footer(
         &self,
         _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
+        _cx: &mut Context<Picker<Self>>,
     ) -> Option<gpui::AnyElement> {
-        let focus_handle = self.focus_handle.clone();
-
         if !self.popover_styles {
             return None;
         }
 
-        Some(
-            h_flex()
-                .w_full()
-                .p_1p5()
-                .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
-                .child(
-                    Button::new("configure", "Configure")
-                        .full_width()
-                        .style(ButtonStyle::Outlined)
-                        .key_binding(
-                            KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
-                                .map(|kb| kb.size(rems_from_px(12.))),
-                        )
-                        .on_click(|_, window, cx| {
-                            window.dispatch_action(OpenSettings.boxed_clone(), cx);
-                        }),
-                )
-                .into_any(),
-        )
+        let focus_handle = self.focus_handle.clone();
+
+        Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
     }
 }
 

crates/agent_ui/src/ui.rs 🔗

@@ -4,8 +4,8 @@ mod burn_mode_tooltip;
 mod claude_code_onboarding_modal;
 mod end_trial_upsell;
 mod hold_for_default;
+mod model_selector_components;
 mod onboarding_modal;
-
 mod usage_callout;
 
 pub use acp_onboarding_modal::*;
@@ -14,6 +14,6 @@ pub use burn_mode_tooltip::*;
 pub use claude_code_onboarding_modal::*;
 pub use end_trial_upsell::*;
 pub use hold_for_default::*;
+pub use model_selector_components::*;
 pub use onboarding_modal::*;
-
 pub use usage_callout::*;

crates/agent_ui/src/ui/model_selector_components.rs 🔗

@@ -0,0 +1,147 @@
+use gpui::{Action, FocusHandle, prelude::*};
+use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
+
+#[derive(IntoElement)]
+pub struct ModelSelectorHeader {
+    title: SharedString,
+    has_border: bool,
+}
+
+impl ModelSelectorHeader {
+    pub fn new(title: impl Into<SharedString>, has_border: bool) -> Self {
+        Self {
+            title: title.into(),
+            has_border,
+        }
+    }
+}
+
+impl RenderOnce for ModelSelectorHeader {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        div()
+            .px_2()
+            .pb_1()
+            .when(self.has_border, |this| {
+                this.mt_1()
+                    .pt_2()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+            })
+            .child(
+                Label::new(self.title)
+                    .size(LabelSize::XSmall)
+                    .color(Color::Muted),
+            )
+    }
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorListItem {
+    index: usize,
+    title: SharedString,
+    icon: Option<IconName>,
+    is_selected: bool,
+    is_focused: bool,
+}
+
+impl ModelSelectorListItem {
+    pub fn new(index: usize, title: impl Into<SharedString>) -> Self {
+        Self {
+            index,
+            title: title.into(),
+            icon: None,
+            is_selected: false,
+            is_focused: false,
+        }
+    }
+
+    pub fn icon(mut self, icon: IconName) -> Self {
+        self.icon = Some(icon);
+        self
+    }
+
+    pub fn is_selected(mut self, is_selected: bool) -> Self {
+        self.is_selected = is_selected;
+        self
+    }
+
+    pub fn is_focused(mut self, is_focused: bool) -> Self {
+        self.is_focused = is_focused;
+        self
+    }
+}
+
+impl RenderOnce for ModelSelectorListItem {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let model_icon_color = if self.is_selected {
+            Color::Accent
+        } else {
+            Color::Muted
+        };
+
+        ListItem::new(self.index)
+            .inset(true)
+            .spacing(ListItemSpacing::Sparse)
+            .toggle_state(self.is_focused)
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_1p5()
+                    .when_some(self.icon, |this, icon| {
+                        this.child(
+                            Icon::new(icon)
+                                .color(model_icon_color)
+                                .size(IconSize::Small),
+                        )
+                    })
+                    .child(Label::new(self.title).truncate()),
+            )
+            .end_slot(div().pr_2().when(self.is_selected, |this| {
+                this.child(
+                    Icon::new(IconName::Check)
+                        .color(Color::Accent)
+                        .size(IconSize::Small),
+                )
+            }))
+    }
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorFooter {
+    action: Box<dyn Action>,
+    focus_handle: FocusHandle,
+}
+
+impl ModelSelectorFooter {
+    pub fn new(action: Box<dyn Action>, focus_handle: FocusHandle) -> Self {
+        Self {
+            action,
+            focus_handle,
+        }
+    }
+}
+
+impl RenderOnce for ModelSelectorFooter {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let action = self.action;
+        let focus_handle = self.focus_handle;
+
+        h_flex()
+            .w_full()
+            .p_1p5()
+            .border_t_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(
+                Button::new("configure", "Configure")
+                    .full_width()
+                    .style(ButtonStyle::Outlined)
+                    .key_binding(
+                        KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx)
+                            .map(|kb| kb.size(rems_from_px(12.))),
+                    )
+                    .on_click(move |_, window, cx| {
+                        window.dispatch_action(action.boxed_clone(), cx);
+                    }),
+            )
+    }
+}