diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index af2354f7a854fd84483889f18e0f51e1c294d8a2..017f499d67e31b8a1a543a2a2194042f0f34b7c7 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -3,12 +3,19 @@ use agent_settings::{ AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, }; use fs::Fs; -use gpui::{Action, Entity, FocusHandle, Subscription, prelude::*}; -use settings::{DockPosition, Settings as _, SettingsStore, update_settings_file}; -use std::sync::Arc; +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use gpui::{ + Action, AnyElement, App, BackgroundExecutor, Context, DismissEvent, Entity, FocusHandle, + Focusable, SharedString, Subscription, Task, Window, +}; +use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu}; +use settings::{Settings as _, SettingsStore, update_settings_file}; +use std::{ + sync::atomic::Ordering, + sync::{Arc, atomic::AtomicBool}, +}; use ui::{ - ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, PopoverMenu, - PopoverMenuHandle, TintColor, Tooltip, prelude::*, + HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; /// Trait for types that can provide and manage agent profiles @@ -25,9 +32,11 @@ pub trait ProfileProvider { pub struct ProfileSelector { profiles: AvailableProfiles, + pending_refresh: bool, fs: Arc, provider: Arc, - menu_handle: PopoverMenuHandle, + picker: Option>>, + picker_handle: PopoverMenuHandle>, focus_handle: FocusHandle, _subscriptions: Vec, } @@ -40,188 +49,664 @@ impl ProfileSelector { cx: &mut Context, ) -> Self { let settings_subscription = cx.observe_global::(move |this, cx| { - this.refresh_profiles(cx); + this.pending_refresh = true; + cx.notify(); }); Self { profiles: AgentProfile::available_profiles(cx), + pending_refresh: false, fs, provider, - menu_handle: PopoverMenuHandle::default(), + picker: None, + picker_handle: PopoverMenuHandle::default(), focus_handle, _subscriptions: vec![settings_subscription], } } - pub fn menu_handle(&self) -> PopoverMenuHandle { - self.menu_handle.clone() - } - - fn refresh_profiles(&mut self, cx: &mut Context) { - self.profiles = AgentProfile::available_profiles(cx); + pub fn menu_handle(&self) -> PopoverMenuHandle> { + self.picker_handle.clone() } - fn build_context_menu( - &self, + fn ensure_picker( + &mut self, window: &mut Window, cx: &mut Context, - ) -> Entity { - ContextMenu::build(window, cx, |mut menu, _window, cx| { - let settings = AgentSettings::get_global(cx); - - let mut found_non_builtin = false; - for (profile_id, profile_name) in self.profiles.iter() { - if !builtin_profiles::is_builtin(profile_id) { - found_non_builtin = true; - continue; - } - menu = menu.item(self.menu_entry_for_profile( - profile_id.clone(), - profile_name, - settings, - cx, - )); - } + ) -> Entity> { + if self.picker.is_none() { + let delegate = ProfilePickerDelegate::new( + self.fs.clone(), + self.provider.clone(), + self.profiles.clone(), + cx.background_executor().clone(), + cx, + ); - if found_non_builtin { - menu = menu.separator().header("Custom Profiles"); - for (profile_id, profile_name) in self.profiles.iter() { - if builtin_profiles::is_builtin(profile_id) { - continue; - } - menu = menu.item(self.menu_entry_for_profile( - profile_id.clone(), - profile_name, - settings, - cx, - )); - } + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .show_scrollbar(true) + .width(rems(20.)) + .max_height(Some(rems(16.).into())) + }); + + self.picker = Some(picker); + } + + if self.pending_refresh { + if let Some(picker) = &self.picker { + let profiles = AgentProfile::available_profiles(cx); + self.profiles = profiles.clone(); + picker.update(cx, |picker, cx| { + let query = picker.query(cx); + picker + .delegate + .refresh_profiles(profiles.clone(), query, cx); + }); } + self.pending_refresh = false; + } - menu = menu.separator(); - menu = menu.item(ContextMenuEntry::new("Configure Profiles…").handler( - move |window, cx| { - window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); - }, - )); + self.picker.as_ref().unwrap().clone() + } +} - menu - }) +impl Focusable for ProfileSelector { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if let Some(picker) = &self.picker { + picker.focus_handle(cx) + } else { + self.focus_handle.clone() + } } +} - fn menu_entry_for_profile( - &self, - profile_id: AgentProfileId, - profile_name: &SharedString, - settings: &AgentSettings, - cx: &App, - ) -> ContextMenuEntry { - let documentation = match profile_name.to_lowercase().as_str() { +impl Render for ProfileSelector { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.provider.profiles_supported(cx) { + return Button::new("tools-not-supported-button", "Tools Unsupported") + .disabled(true) + .label_size(LabelSize::Small) + .color(Color::Muted) + .tooltip(Tooltip::text("This model does not support tools.")) + .into_any_element(); + } + + let picker = self.ensure_picker(window, cx); + + let settings = AgentSettings::get_global(cx); + let profile_id = self.provider.profile_id(cx); + let profile = settings.profiles.get(&profile_id); + + let selected_profile = profile + .map(|profile| profile.name.clone()) + .unwrap_or_else(|| "Unknown".into()); + let focus_handle = self.focus_handle.clone(); + + let trigger_button = Button::new("profile-selector", selected_profile) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(IconName::ChevronDown) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)); + + PickerPopoverMenu::new( + picker, + trigger_button, + move |window, cx| { + Tooltip::for_action_in( + "Toggle Profile Menu", + &ToggleProfileSelector, + &focus_handle, + window, + cx, + ) + }, + gpui::Corner::BottomRight, + cx, + ) + .with_handle(self.picker_handle.clone()) + .render(window, cx) + .into_any_element() + } +} + +#[derive(Clone)] +struct ProfileCandidate { + id: AgentProfileId, + name: SharedString, + is_builtin: bool, +} + +#[derive(Clone)] +struct ProfileMatchEntry { + candidate_index: usize, + positions: Vec, +} + +enum ProfilePickerEntry { + Header(SharedString), + Profile(ProfileMatchEntry), +} + +pub(crate) struct ProfilePickerDelegate { + fs: Arc, + provider: Arc, + background: BackgroundExecutor, + candidates: Vec, + string_candidates: Arc>, + filtered_entries: Vec, + selected_index: usize, + query: String, + cancel: Option>, +} + +impl ProfilePickerDelegate { + fn new( + fs: Arc, + provider: Arc, + profiles: AvailableProfiles, + background: BackgroundExecutor, + cx: &mut Context, + ) -> Self { + let candidates = Self::candidates_from(profiles); + let string_candidates = Arc::new(Self::string_candidates(&candidates)); + let filtered_entries = Self::entries_from_candidates(&candidates); + + let mut this = Self { + fs, + provider, + background, + candidates, + string_candidates, + filtered_entries, + selected_index: 0, + query: String::new(), + cancel: None, + }; + + this.selected_index = this + .index_of_profile(&this.provider.profile_id(cx)) + .unwrap_or_else(|| this.first_selectable_index().unwrap_or(0)); + + this + } + + fn refresh_profiles( + &mut self, + profiles: AvailableProfiles, + query: String, + cx: &mut Context>, + ) { + self.candidates = Self::candidates_from(profiles); + self.string_candidates = Arc::new(Self::string_candidates(&self.candidates)); + self.query = query; + + if self.query.is_empty() { + self.filtered_entries = Self::entries_from_candidates(&self.candidates); + } else { + let matches = self.search_blocking(&self.query); + self.filtered_entries = self.entries_from_matches(matches); + } + + self.selected_index = self + .index_of_profile(&self.provider.profile_id(cx)) + .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0)); + cx.notify(); + } + + fn candidates_from(profiles: AvailableProfiles) -> Vec { + profiles + .into_iter() + .map(|(id, name)| ProfileCandidate { + is_builtin: builtin_profiles::is_builtin(&id), + id, + name, + }) + .collect() + } + + fn string_candidates(candidates: &[ProfileCandidate]) -> Vec { + candidates + .iter() + .enumerate() + .map(|(index, candidate)| StringMatchCandidate::new(index, candidate.name.as_ref())) + .collect() + } + + fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> { + match candidate.id.as_str() { builtin_profiles::WRITE => Some("Get help to write anything."), builtin_profiles::ASK => Some("Chat about your codebase."), builtin_profiles::MINIMAL => Some("Chat about anything with no tools."), _ => None, - }; - let thread_profile_id = self.provider.profile_id(cx); + } + } - let entry = ContextMenuEntry::new(profile_name.clone()) - .toggleable(IconPosition::End, profile_id == thread_profile_id); + fn entries_from_candidates(candidates: &[ProfileCandidate]) -> Vec { + let mut entries = Vec::new(); + let mut inserted_custom_header = false; - let entry = if let Some(doc_text) = documentation { - entry.documentation_aside( - documentation_side(settings.dock), - DocumentationEdge::Top, - move |_| Label::new(doc_text).into_any_element(), - ) - } else { - entry - }; + for (idx, candidate) in candidates.iter().enumerate() { + if !candidate.is_builtin && !inserted_custom_header { + if !entries.is_empty() { + entries.push(ProfilePickerEntry::Header("Custom Profiles".into())); + } + inserted_custom_header = true; + } - entry.handler({ - let fs = self.fs.clone(); - let provider = self.provider.clone(); - move |_window, cx| { - update_settings_file(fs.clone(), cx, { - let profile_id = profile_id.clone(); - move |settings, _cx| { - settings - .agent - .get_or_insert_default() - .set_profile(profile_id.0); - } - }); + entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: idx, + positions: Vec::new(), + })); + } + + entries + } - provider.set_profile(profile_id.clone(), cx); + fn entries_from_matches(&self, matches: Vec) -> Vec { + let mut entries = Vec::new(); + for mat in matches { + if self.candidates.get(mat.candidate_id).is_some() { + entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: mat.candidate_id, + positions: mat.positions, + })); } + } + entries + } + + fn first_selectable_index(&self) -> Option { + self.filtered_entries + .iter() + .position(|entry| matches!(entry, ProfilePickerEntry::Profile(_))) + } + + fn index_of_profile(&self, profile_id: &AgentProfileId) -> Option { + self.filtered_entries.iter().position(|entry| { + matches!(entry, ProfilePickerEntry::Profile(profile) if self + .candidates + .get(profile.candidate_index) + .map(|candidate| &candidate.id == profile_id) + .unwrap_or(false)) }) } + + fn search_blocking(&self, query: &str) -> Vec { + if query.is_empty() { + return self + .string_candidates + .iter() + .map(|candidate| StringMatch { + candidate_id: candidate.id, + score: 0.0, + positions: Vec::new(), + string: candidate.string.clone(), + }) + .collect(); + } + + let cancel_flag = AtomicBool::new(false); + + self.background.block(match_strings( + self.string_candidates.as_ref(), + query, + false, + true, + 100, + &cancel_flag, + self.background.clone(), + )) + } } -impl Render for ProfileSelector { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = AgentSettings::get_global(cx); - let profile_id = self.provider.profile_id(cx); - let profile = settings.profiles.get(&profile_id); +impl PickerDelegate for ProfilePickerDelegate { + type ListItem = AnyElement; - let selected_profile = profile - .map(|profile| profile.name.clone()) - .unwrap_or_else(|| "Unknown".into()); + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { + "Search profiles…".into() + } - if self.provider.profiles_supported(cx) { - let this = cx.entity(); - let focus_handle = self.focus_handle.clone(); - let trigger_button = Button::new("profile-selector-model", selected_profile) - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(IconName::ChevronDown) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)); - - PopoverMenu::new("profile-selector") - .trigger_with_tooltip(trigger_button, { - move |window, cx| { - Tooltip::for_action_in( - "Toggle Profile Menu", - &ToggleProfileSelector, - &focus_handle, - window, - cx, + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + let text = if self.candidates.is_empty() { + "No profiles.".into() + } else { + "No profiles match your search.".into() + }; + Some(text) + } + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { + self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); + cx.notify(); + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + match self.filtered_entries.get(ix) { + Some(ProfilePickerEntry::Profile(_)) => true, + Some(ProfilePickerEntry::Header(_)) | None => false, + } + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.query.clear(); + self.filtered_entries = Self::entries_from_candidates(&self.candidates); + self.selected_index = self + .index_of_profile(&self.provider.profile_id(cx)) + .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0)); + cx.notify(); + return Task::ready(()); + } + + if let Some(prev) = &self.cancel { + prev.store(true, Ordering::Relaxed); + } + let cancel = Arc::new(AtomicBool::new(false)); + self.cancel = Some(cancel.clone()); + + let string_candidates = self.string_candidates.clone(); + let background = self.background.clone(); + let provider = self.provider.clone(); + self.query = query.clone(); + + let cancel_for_future = cancel; + + cx.spawn_in(window, async move |this, cx| { + let matches = match_strings( + string_candidates.as_ref(), + &query, + false, + true, + 100, + cancel_for_future.as_ref(), + background, + ) + .await; + + this.update_in(cx, |this, _, cx| { + if this.delegate.query != query { + return; + } + + this.delegate.filtered_entries = this.delegate.entries_from_matches(matches); + this.delegate.selected_index = this + .delegate + .index_of_profile(&provider.profile_id(cx)) + .unwrap_or_else(|| this.delegate.first_selectable_index().unwrap_or(0)); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context>) { + match self.filtered_entries.get(self.selected_index) { + Some(ProfilePickerEntry::Profile(entry)) => { + if let Some(candidate) = self.candidates.get(entry.candidate_index) { + let profile_id = candidate.id.clone(); + let fs = self.fs.clone(); + let provider = self.provider.clone(); + + update_settings_file(fs, cx, { + let profile_id = profile_id.clone(); + move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_profile(profile_id.0); + } + }); + + provider.set_profile(profile_id.clone(), cx); + + telemetry::event!( + "agent_profile_switched", + profile_id = profile_id.as_str(), + source = "picker" + ); + } + + cx.emit(DismissEvent); + } + _ => {} + } + } + + fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + cx.defer_in(window, |picker, window, cx| { + picker.set_query("", window, cx); + }); + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.filtered_entries.get(ix)? { + ProfilePickerEntry::Header(label) => Some( + div() + .px_2p5() + .pb_0p5() + .when(ix > 0, |this| { + this.mt_1p5() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(label.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + ), + ProfilePickerEntry::Profile(entry) => { + let candidate = self.candidates.get(entry.candidate_index)?; + let active_id = self.provider.profile_id(cx); + let is_active = active_id == candidate.id; + + Some( + ListItem::new(SharedString::from(candidate.id.0.clone())) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + v_flex() + .child(HighlightedLabel::new( + candidate.name.clone(), + entry.positions.clone(), + )) + .when_some(Self::documentation(candidate), |this, doc| { + this.child( + Label::new(doc).size(LabelSize::Small).color(Color::Muted), + ) + }), ) - } - }) - .anchor( - if documentation_side(settings.dock) == DocumentationSide::Left { - gpui::Corner::BottomRight - } else { - gpui::Corner::BottomLeft - }, + .when(is_active, |this| { + this.end_slot( + div() + .pr_2() + .child(Icon::new(IconName::Check).color(Color::Accent)), + ) + }) + .into_any_element(), ) - .with_handle(self.menu_handle.clone()) - .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) - }) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .into_any_element() - } else { - Button::new("tools-not-supported-button", "Tools Unsupported") - .disabled(true) - .label_size(LabelSize::Small) - .color(Color::Muted) - .tooltip(Tooltip::text("This model does not support tools.")) - .into_any_element() + } } } + + fn render_footer( + &self, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + Some( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1() + .gap_4() + .justify_between() + .child( + Button::new("configure", "Configure") + .icon(IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(|_, window, cx| { + window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); + }), + ) + .into_any(), + ) + } } -fn documentation_side(position: DockPosition) -> DocumentationSide { - match position { - DockPosition::Left => DocumentationSide::Right, - DockPosition::Bottom => DocumentationSide::Left, - DockPosition::Right => DocumentationSide::Left, +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; + + #[gpui::test] + fn entries_include_custom_profiles(_cx: &mut TestAppContext) { + let candidates = vec![ + ProfileCandidate { + id: AgentProfileId("write".into()), + name: SharedString::from("Write"), + is_builtin: true, + }, + ProfileCandidate { + id: AgentProfileId("my-custom".into()), + name: SharedString::from("My Custom"), + is_builtin: false, + }, + ]; + + let entries = ProfilePickerDelegate::entries_from_candidates(&candidates); + + assert!(entries.iter().any(|entry| matches!( + entry, + ProfilePickerEntry::Profile(profile) + if candidates[profile.candidate_index].id.as_str() == "my-custom" + ))); + assert!(entries.iter().any(|entry| matches!( + entry, + ProfilePickerEntry::Header(label) if label.as_ref() == "Custom Profiles" + ))); + } + + #[gpui::test] + fn fuzzy_filter_returns_no_results_and_keeps_configure(cx: &mut TestAppContext) { + let candidates = vec![ProfileCandidate { + id: AgentProfileId("write".into()), + name: SharedString::from("Write"), + is_builtin: true, + }]; + + let delegate = ProfilePickerDelegate { + fs: FakeFs::new(cx.executor()), + provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), + background: cx.executor(), + candidates, + string_candidates: Arc::new(Vec::new()), + filtered_entries: Vec::new(), + selected_index: 0, + query: String::new(), + cancel: None, + }; + + let matches = Vec::new(); // No matches + let _entries = delegate.entries_from_matches(matches); + } + + #[gpui::test] + fn active_profile_selection_logic_works(cx: &mut TestAppContext) { + let candidates = vec![ + ProfileCandidate { + id: AgentProfileId("write".into()), + name: SharedString::from("Write"), + is_builtin: true, + }, + ProfileCandidate { + id: AgentProfileId("ask".into()), + name: SharedString::from("Ask"), + is_builtin: true, + }, + ]; + + let delegate = ProfilePickerDelegate { + fs: FakeFs::new(cx.executor()), + provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), + background: cx.executor(), + candidates, + string_candidates: Arc::new(Vec::new()), + filtered_entries: vec![ + ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: 0, + positions: Vec::new(), + }), + ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: 1, + positions: Vec::new(), + }), + ], + selected_index: 0, + query: String::new(), + cancel: None, + }; + + // Active profile should be found at index 0 + let active_index = delegate.index_of_profile(&AgentProfileId("write".into())); + assert_eq!(active_index, Some(0)); + } + + struct TestProfileProvider { + profile_id: AgentProfileId, + } + + impl TestProfileProvider { + fn new(profile_id: AgentProfileId) -> Self { + Self { profile_id } + } + } + + impl ProfileProvider for TestProfileProvider { + fn profile_id(&self, _cx: &App) -> AgentProfileId { + self.profile_id.clone() + } + + fn set_profile(&self, _profile_id: AgentProfileId, _cx: &mut App) {} + + fn profiles_supported(&self, _cx: &App) -> bool { + true + } } }