agent: Make the profile switcher a picker (#39218)
Split off from https://github.com/zed-industries/zed/pull/39175 Adds a search bar to the 'profile' panel, so that we can switch profiles without having to use the mouse or `tab` a few times  Release Notes: - agent: Added the ability to search profiles in the agent panel's profile picker. --------- Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
@@ -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<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
picker: Option<Entity<Picker<ProfilePickerDelegate>>>,
|
||||
picker_handle: PopoverMenuHandle<Picker<ProfilePickerDelegate>>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -40,125 +49,91 @@ impl ProfileSelector {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let settings_subscription = cx.observe_global::<SettingsStore>(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<ContextMenu> {
|
||||
self.menu_handle.clone()
|
||||
pub fn menu_handle(&self) -> PopoverMenuHandle<Picker<ProfilePickerDelegate>> {
|
||||
self.picker_handle.clone()
|
||||
}
|
||||
|
||||
fn refresh_profiles(&mut self, cx: &mut Context<Self>) {
|
||||
self.profiles = AgentProfile::available_profiles(cx);
|
||||
}
|
||||
|
||||
fn build_context_menu(
|
||||
&self,
|
||||
fn ensure_picker(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<ContextMenu> {
|
||||
ContextMenu::build(window, cx, |mut menu, _window, cx| {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
) -> Entity<Picker<ProfilePickerDelegate>> {
|
||||
if self.picker.is_none() {
|
||||
let delegate = ProfilePickerDelegate::new(
|
||||
self.fs.clone(),
|
||||
self.provider.clone(),
|
||||
self.profiles.clone(),
|
||||
cx.background_executor().clone(),
|
||||
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,
|
||||
));
|
||||
}
|
||||
let picker = cx.new(|cx| {
|
||||
Picker::list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems(20.))
|
||||
.max_height(Some(rems(16.).into()))
|
||||
});
|
||||
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
self.picker = Some(picker);
|
||||
}
|
||||
|
||||
menu = menu.separator();
|
||||
menu = menu.item(ContextMenuEntry::new("Configure Profiles…").handler(
|
||||
move |window, cx| {
|
||||
window.dispatch_action(ManageProfiles::default().boxed_clone(), cx);
|
||||
},
|
||||
));
|
||||
|
||||
menu
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
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);
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
provider.set_profile(profile_id.clone(), cx);
|
||||
}
|
||||
})
|
||||
self.pending_refresh = false;
|
||||
}
|
||||
|
||||
self.picker.as_ref().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfileSelector {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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);
|
||||
@@ -166,62 +141,572 @@ impl Render for ProfileSelector {
|
||||
let selected_profile = profile
|
||||
.map(|profile| profile.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
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));
|
||||
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));
|
||||
|
||||
PopoverMenu::new("profile-selector")
|
||||
.trigger_with_tooltip(trigger_button, {
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Profile Menu",
|
||||
&ToggleProfileSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.anchor(
|
||||
if documentation_side(settings.dock) == DocumentationSide::Left {
|
||||
gpui::Corner::BottomRight
|
||||
} else {
|
||||
gpui::Corner::BottomLeft
|
||||
},
|
||||
PickerPopoverMenu::new(
|
||||
picker,
|
||||
trigger_button,
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Profile Menu",
|
||||
&ToggleProfileSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.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()
|
||||
},
|
||||
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<usize>,
|
||||
}
|
||||
|
||||
enum ProfilePickerEntry {
|
||||
Header(SharedString),
|
||||
Profile(ProfileMatchEntry),
|
||||
}
|
||||
|
||||
pub(crate) struct ProfilePickerDelegate {
|
||||
fs: Arc<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
background: BackgroundExecutor,
|
||||
candidates: Vec<ProfileCandidate>,
|
||||
string_candidates: Arc<Vec<StringMatchCandidate>>,
|
||||
filtered_entries: Vec<ProfilePickerEntry>,
|
||||
selected_index: usize,
|
||||
query: String,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ProfilePickerDelegate {
|
||||
fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
profiles: AvailableProfiles,
|
||||
background: BackgroundExecutor,
|
||||
cx: &mut Context<ProfileSelector>,
|
||||
) -> 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<Picker<Self>>,
|
||||
) {
|
||||
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 {
|
||||
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 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<ProfileCandidate> {
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|(id, name)| ProfileCandidate {
|
||||
is_builtin: builtin_profiles::is_builtin(&id),
|
||||
id,
|
||||
name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn string_candidates(candidates: &[ProfileCandidate]) -> Vec<StringMatchCandidate> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_from_candidates(candidates: &[ProfileCandidate]) -> Vec<ProfilePickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut inserted_custom_header = false;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
|
||||
candidate_index: idx,
|
||||
positions: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn entries_from_matches(&self, matches: Vec<StringMatch>) -> Vec<ProfilePickerEntry> {
|
||||
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<usize> {
|
||||
self.filtered_entries
|
||||
.iter()
|
||||
.position(|entry| matches!(entry, ProfilePickerEntry::Profile(_)))
|
||||
}
|
||||
|
||||
fn index_of_profile(&self, profile_id: &AgentProfileId) -> Option<usize> {
|
||||
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<StringMatch> {
|
||||
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 PickerDelegate for ProfilePickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc<str> {
|
||||
"Search profiles…".into()
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
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<Picker<Self>>) {
|
||||
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<Picker<Self>>,
|
||||
) -> 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<Picker<Self>>,
|
||||
) -> 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<Picker<Self>>) {
|
||||
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<Picker<Self>>) {
|
||||
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<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(is_active, |this| {
|
||||
this.end_slot(
|
||||
div()
|
||||
.pr_2()
|
||||
.child(Icon::new(IconName::Check).color(Color::Accent)),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn documentation_side(position: DockPosition) -> DocumentationSide {
|
||||
match position {
|
||||
DockPosition::Left => DocumentationSide::Right,
|
||||
DockPosition::Bottom => DocumentationSide::Left,
|
||||
DockPosition::Right => DocumentationSide::Left,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user