profile_selector.rs

  1use crate::{ManageProfiles, ToggleProfileSelector};
  2use agent_settings::{
  3    AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles,
  4};
  5use fs::Fs;
  6use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
  7use gpui::{
  8    Action, AnyElement, App, BackgroundExecutor, Context, DismissEvent, Entity, FocusHandle,
  9    Focusable, SharedString, Subscription, Task, Window,
 10};
 11use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu};
 12use settings::{Settings as _, SettingsStore, update_settings_file};
 13use std::{
 14    sync::atomic::Ordering,
 15    sync::{Arc, atomic::AtomicBool},
 16};
 17use ui::{
 18    HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
 19};
 20
 21/// Trait for types that can provide and manage agent profiles
 22pub trait ProfileProvider {
 23    /// Get the current profile ID
 24    fn profile_id(&self, cx: &App) -> AgentProfileId;
 25
 26    /// Set the profile ID
 27    fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App);
 28
 29    /// Check if profiles are supported in the current context (e.g. if the model that is selected has tool support)
 30    fn profiles_supported(&self, cx: &App) -> bool;
 31}
 32
 33pub struct ProfileSelector {
 34    profiles: AvailableProfiles,
 35    pending_refresh: bool,
 36    fs: Arc<dyn Fs>,
 37    provider: Arc<dyn ProfileProvider>,
 38    picker: Option<Entity<Picker<ProfilePickerDelegate>>>,
 39    picker_handle: PopoverMenuHandle<Picker<ProfilePickerDelegate>>,
 40    focus_handle: FocusHandle,
 41    _subscriptions: Vec<Subscription>,
 42}
 43
 44impl ProfileSelector {
 45    pub fn new(
 46        fs: Arc<dyn Fs>,
 47        provider: Arc<dyn ProfileProvider>,
 48        focus_handle: FocusHandle,
 49        cx: &mut Context<Self>,
 50    ) -> Self {
 51        let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
 52            this.pending_refresh = true;
 53            cx.notify();
 54        });
 55
 56        Self {
 57            profiles: AgentProfile::available_profiles(cx),
 58            pending_refresh: false,
 59            fs,
 60            provider,
 61            picker: None,
 62            picker_handle: PopoverMenuHandle::default(),
 63            focus_handle,
 64            _subscriptions: vec![settings_subscription],
 65        }
 66    }
 67
 68    pub fn menu_handle(&self) -> PopoverMenuHandle<Picker<ProfilePickerDelegate>> {
 69        self.picker_handle.clone()
 70    }
 71
 72    fn ensure_picker(
 73        &mut self,
 74        window: &mut Window,
 75        cx: &mut Context<Self>,
 76    ) -> Entity<Picker<ProfilePickerDelegate>> {
 77        if self.picker.is_none() {
 78            let delegate = ProfilePickerDelegate::new(
 79                self.fs.clone(),
 80                self.provider.clone(),
 81                self.profiles.clone(),
 82                cx.background_executor().clone(),
 83                cx,
 84            );
 85
 86            let picker = cx.new(|cx| {
 87                Picker::list(delegate, window, cx)
 88                    .show_scrollbar(true)
 89                    .width(rems(20.))
 90                    .max_height(Some(rems(16.).into()))
 91            });
 92
 93            self.picker = Some(picker);
 94        }
 95
 96        if self.pending_refresh {
 97            if let Some(picker) = &self.picker {
 98                let profiles = AgentProfile::available_profiles(cx);
 99                self.profiles = profiles.clone();
100                picker.update(cx, |picker, cx| {
101                    let query = picker.query(cx);
102                    picker
103                        .delegate
104                        .refresh_profiles(profiles.clone(), query, cx);
105                });
106            }
107            self.pending_refresh = false;
108        }
109
110        self.picker.as_ref().unwrap().clone()
111    }
112}
113
114impl Focusable for ProfileSelector {
115    fn focus_handle(&self, cx: &App) -> FocusHandle {
116        if let Some(picker) = &self.picker {
117            picker.focus_handle(cx)
118        } else {
119            self.focus_handle.clone()
120        }
121    }
122}
123
124impl Render for ProfileSelector {
125    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
126        if !self.provider.profiles_supported(cx) {
127            return Button::new("tools-not-supported-button", "Tools Unsupported")
128                .disabled(true)
129                .label_size(LabelSize::Small)
130                .color(Color::Muted)
131                .tooltip(Tooltip::text("This model does not support tools."))
132                .into_any_element();
133        }
134
135        let picker = self.ensure_picker(window, cx);
136
137        let settings = AgentSettings::get_global(cx);
138        let profile_id = self.provider.profile_id(cx);
139        let profile = settings.profiles.get(&profile_id);
140
141        let selected_profile = profile
142            .map(|profile| profile.name.clone())
143            .unwrap_or_else(|| "Unknown".into());
144        let focus_handle = self.focus_handle.clone();
145
146        let trigger_button = Button::new("profile-selector", selected_profile)
147            .label_size(LabelSize::Small)
148            .color(Color::Muted)
149            .icon(IconName::ChevronDown)
150            .icon_size(IconSize::XSmall)
151            .icon_position(IconPosition::End)
152            .icon_color(Color::Muted)
153            .selected_style(ButtonStyle::Tinted(TintColor::Accent));
154
155        PickerPopoverMenu::new(
156            picker,
157            trigger_button,
158            move |window, cx| {
159                Tooltip::for_action_in(
160                    "Toggle Profile Menu",
161                    &ToggleProfileSelector,
162                    &focus_handle,
163                    window,
164                    cx,
165                )
166            },
167            gpui::Corner::BottomRight,
168            cx,
169        )
170        .with_handle(self.picker_handle.clone())
171        .render(window, cx)
172        .into_any_element()
173    }
174}
175
176#[derive(Clone)]
177struct ProfileCandidate {
178    id: AgentProfileId,
179    name: SharedString,
180    is_builtin: bool,
181}
182
183#[derive(Clone)]
184struct ProfileMatchEntry {
185    candidate_index: usize,
186    positions: Vec<usize>,
187}
188
189enum ProfilePickerEntry {
190    Header(SharedString),
191    Profile(ProfileMatchEntry),
192}
193
194pub(crate) struct ProfilePickerDelegate {
195    fs: Arc<dyn Fs>,
196    provider: Arc<dyn ProfileProvider>,
197    background: BackgroundExecutor,
198    candidates: Vec<ProfileCandidate>,
199    string_candidates: Arc<Vec<StringMatchCandidate>>,
200    filtered_entries: Vec<ProfilePickerEntry>,
201    selected_index: usize,
202    query: String,
203    cancel: Option<Arc<AtomicBool>>,
204}
205
206impl ProfilePickerDelegate {
207    fn new(
208        fs: Arc<dyn Fs>,
209        provider: Arc<dyn ProfileProvider>,
210        profiles: AvailableProfiles,
211        background: BackgroundExecutor,
212        cx: &mut Context<ProfileSelector>,
213    ) -> Self {
214        let candidates = Self::candidates_from(profiles);
215        let string_candidates = Arc::new(Self::string_candidates(&candidates));
216        let filtered_entries = Self::entries_from_candidates(&candidates);
217
218        let mut this = Self {
219            fs,
220            provider,
221            background,
222            candidates,
223            string_candidates,
224            filtered_entries,
225            selected_index: 0,
226            query: String::new(),
227            cancel: None,
228        };
229
230        this.selected_index = this
231            .index_of_profile(&this.provider.profile_id(cx))
232            .unwrap_or_else(|| this.first_selectable_index().unwrap_or(0));
233
234        this
235    }
236
237    fn refresh_profiles(
238        &mut self,
239        profiles: AvailableProfiles,
240        query: String,
241        cx: &mut Context<Picker<Self>>,
242    ) {
243        self.candidates = Self::candidates_from(profiles);
244        self.string_candidates = Arc::new(Self::string_candidates(&self.candidates));
245        self.query = query;
246
247        if self.query.is_empty() {
248            self.filtered_entries = Self::entries_from_candidates(&self.candidates);
249        } else {
250            let matches = self.search_blocking(&self.query);
251            self.filtered_entries = self.entries_from_matches(matches);
252        }
253
254        self.selected_index = self
255            .index_of_profile(&self.provider.profile_id(cx))
256            .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0));
257        cx.notify();
258    }
259
260    fn candidates_from(profiles: AvailableProfiles) -> Vec<ProfileCandidate> {
261        profiles
262            .into_iter()
263            .map(|(id, name)| ProfileCandidate {
264                is_builtin: builtin_profiles::is_builtin(&id),
265                id,
266                name,
267            })
268            .collect()
269    }
270
271    fn string_candidates(candidates: &[ProfileCandidate]) -> Vec<StringMatchCandidate> {
272        candidates
273            .iter()
274            .enumerate()
275            .map(|(index, candidate)| StringMatchCandidate::new(index, candidate.name.as_ref()))
276            .collect()
277    }
278
279    fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> {
280        match candidate.id.as_str() {
281            builtin_profiles::WRITE => Some("Get help to write anything."),
282            builtin_profiles::ASK => Some("Chat about your codebase."),
283            builtin_profiles::MINIMAL => Some("Chat about anything with no tools."),
284            _ => None,
285        }
286    }
287
288    fn entries_from_candidates(candidates: &[ProfileCandidate]) -> Vec<ProfilePickerEntry> {
289        let mut entries = Vec::new();
290        let mut inserted_custom_header = false;
291
292        for (idx, candidate) in candidates.iter().enumerate() {
293            if !candidate.is_builtin && !inserted_custom_header {
294                if !entries.is_empty() {
295                    entries.push(ProfilePickerEntry::Header("Custom Profiles".into()));
296                }
297                inserted_custom_header = true;
298            }
299
300            entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
301                candidate_index: idx,
302                positions: Vec::new(),
303            }));
304        }
305
306        entries
307    }
308
309    fn entries_from_matches(&self, matches: Vec<StringMatch>) -> Vec<ProfilePickerEntry> {
310        let mut entries = Vec::new();
311        for mat in matches {
312            if self.candidates.get(mat.candidate_id).is_some() {
313                entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
314                    candidate_index: mat.candidate_id,
315                    positions: mat.positions,
316                }));
317            }
318        }
319        entries
320    }
321
322    fn first_selectable_index(&self) -> Option<usize> {
323        self.filtered_entries
324            .iter()
325            .position(|entry| matches!(entry, ProfilePickerEntry::Profile(_)))
326    }
327
328    fn index_of_profile(&self, profile_id: &AgentProfileId) -> Option<usize> {
329        self.filtered_entries.iter().position(|entry| {
330            matches!(entry, ProfilePickerEntry::Profile(profile) if self
331                .candidates
332                .get(profile.candidate_index)
333                .map(|candidate| &candidate.id == profile_id)
334                .unwrap_or(false))
335        })
336    }
337
338    fn search_blocking(&self, query: &str) -> Vec<StringMatch> {
339        if query.is_empty() {
340            return self
341                .string_candidates
342                .iter()
343                .map(|candidate| StringMatch {
344                    candidate_id: candidate.id,
345                    score: 0.0,
346                    positions: Vec::new(),
347                    string: candidate.string.clone(),
348                })
349                .collect();
350        }
351
352        let cancel_flag = AtomicBool::new(false);
353
354        self.background.block(match_strings(
355            self.string_candidates.as_ref(),
356            query,
357            false,
358            true,
359            100,
360            &cancel_flag,
361            self.background.clone(),
362        ))
363    }
364}
365
366impl PickerDelegate for ProfilePickerDelegate {
367    type ListItem = AnyElement;
368
369    fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc<str> {
370        "Search profiles…".into()
371    }
372
373    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
374        let text = if self.candidates.is_empty() {
375            "No profiles.".into()
376        } else {
377            "No profiles match your search.".into()
378        };
379        Some(text)
380    }
381
382    fn match_count(&self) -> usize {
383        self.filtered_entries.len()
384    }
385
386    fn selected_index(&self) -> usize {
387        self.selected_index
388    }
389
390    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
391        self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
392        cx.notify();
393    }
394
395    fn can_select(
396        &mut self,
397        ix: usize,
398        _window: &mut Window,
399        _cx: &mut Context<Picker<Self>>,
400    ) -> bool {
401        match self.filtered_entries.get(ix) {
402            Some(ProfilePickerEntry::Profile(_)) => true,
403            Some(ProfilePickerEntry::Header(_)) | None => false,
404        }
405    }
406
407    fn update_matches(
408        &mut self,
409        query: String,
410        window: &mut Window,
411        cx: &mut Context<Picker<Self>>,
412    ) -> Task<()> {
413        if query.is_empty() {
414            self.query.clear();
415            self.filtered_entries = Self::entries_from_candidates(&self.candidates);
416            self.selected_index = self
417                .index_of_profile(&self.provider.profile_id(cx))
418                .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0));
419            cx.notify();
420            return Task::ready(());
421        }
422
423        if let Some(prev) = &self.cancel {
424            prev.store(true, Ordering::Relaxed);
425        }
426        let cancel = Arc::new(AtomicBool::new(false));
427        self.cancel = Some(cancel.clone());
428
429        let string_candidates = self.string_candidates.clone();
430        let background = self.background.clone();
431        let provider = self.provider.clone();
432        self.query = query.clone();
433
434        let cancel_for_future = cancel;
435
436        cx.spawn_in(window, async move |this, cx| {
437            let matches = match_strings(
438                string_candidates.as_ref(),
439                &query,
440                false,
441                true,
442                100,
443                cancel_for_future.as_ref(),
444                background,
445            )
446            .await;
447
448            this.update_in(cx, |this, _, cx| {
449                if this.delegate.query != query {
450                    return;
451                }
452
453                this.delegate.filtered_entries = this.delegate.entries_from_matches(matches);
454                this.delegate.selected_index = this
455                    .delegate
456                    .index_of_profile(&provider.profile_id(cx))
457                    .unwrap_or_else(|| this.delegate.first_selectable_index().unwrap_or(0));
458                cx.notify();
459            })
460            .ok();
461        })
462    }
463
464    fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
465        match self.filtered_entries.get(self.selected_index) {
466            Some(ProfilePickerEntry::Profile(entry)) => {
467                if let Some(candidate) = self.candidates.get(entry.candidate_index) {
468                    let profile_id = candidate.id.clone();
469                    let fs = self.fs.clone();
470                    let provider = self.provider.clone();
471
472                    update_settings_file(fs, cx, {
473                        let profile_id = profile_id.clone();
474                        move |settings, _cx| {
475                            settings
476                                .agent
477                                .get_or_insert_default()
478                                .set_profile(profile_id.0);
479                        }
480                    });
481
482                    provider.set_profile(profile_id.clone(), cx);
483
484                    telemetry::event!(
485                        "agent_profile_switched",
486                        profile_id = profile_id.as_str(),
487                        source = "picker"
488                    );
489                }
490
491                cx.emit(DismissEvent);
492            }
493            _ => {}
494        }
495    }
496
497    fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
498        cx.defer_in(window, |picker, window, cx| {
499            picker.set_query("", window, cx);
500        });
501        cx.emit(DismissEvent);
502    }
503
504    fn render_match(
505        &self,
506        ix: usize,
507        selected: bool,
508        _: &mut Window,
509        cx: &mut Context<Picker<Self>>,
510    ) -> Option<Self::ListItem> {
511        match self.filtered_entries.get(ix)? {
512            ProfilePickerEntry::Header(label) => Some(
513                div()
514                    .px_2p5()
515                    .pb_0p5()
516                    .when(ix > 0, |this| {
517                        this.mt_1p5()
518                            .pt_2()
519                            .border_t_1()
520                            .border_color(cx.theme().colors().border_variant)
521                    })
522                    .child(
523                        Label::new(label.clone())
524                            .size(LabelSize::XSmall)
525                            .color(Color::Muted),
526                    )
527                    .into_any_element(),
528            ),
529            ProfilePickerEntry::Profile(entry) => {
530                let candidate = self.candidates.get(entry.candidate_index)?;
531                let active_id = self.provider.profile_id(cx);
532                let is_active = active_id == candidate.id;
533
534                Some(
535                    ListItem::new(SharedString::from(candidate.id.0.clone()))
536                        .inset(true)
537                        .spacing(ListItemSpacing::Sparse)
538                        .toggle_state(selected)
539                        .child(
540                            v_flex()
541                                .child(HighlightedLabel::new(
542                                    candidate.name.clone(),
543                                    entry.positions.clone(),
544                                ))
545                                .when_some(Self::documentation(candidate), |this, doc| {
546                                    this.child(
547                                        Label::new(doc).size(LabelSize::Small).color(Color::Muted),
548                                    )
549                                }),
550                        )
551                        .when(is_active, |this| {
552                            this.end_slot(
553                                div()
554                                    .pr_2()
555                                    .child(Icon::new(IconName::Check).color(Color::Accent)),
556                            )
557                        })
558                        .into_any_element(),
559                )
560            }
561        }
562    }
563
564    fn render_footer(
565        &self,
566        _: &mut Window,
567        cx: &mut Context<Picker<Self>>,
568    ) -> Option<gpui::AnyElement> {
569        Some(
570            h_flex()
571                .w_full()
572                .border_t_1()
573                .border_color(cx.theme().colors().border_variant)
574                .p_1()
575                .gap_4()
576                .justify_between()
577                .child(
578                    Button::new("configure", "Configure")
579                        .icon(IconName::Settings)
580                        .icon_size(IconSize::Small)
581                        .icon_color(Color::Muted)
582                        .icon_position(IconPosition::Start)
583                        .on_click(|_, window, cx| {
584                            window.dispatch_action(ManageProfiles::default().boxed_clone(), cx);
585                        }),
586                )
587                .into_any(),
588        )
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use fs::FakeFs;
596    use gpui::TestAppContext;
597
598    #[gpui::test]
599    fn entries_include_custom_profiles(_cx: &mut TestAppContext) {
600        let candidates = vec![
601            ProfileCandidate {
602                id: AgentProfileId("write".into()),
603                name: SharedString::from("Write"),
604                is_builtin: true,
605            },
606            ProfileCandidate {
607                id: AgentProfileId("my-custom".into()),
608                name: SharedString::from("My Custom"),
609                is_builtin: false,
610            },
611        ];
612
613        let entries = ProfilePickerDelegate::entries_from_candidates(&candidates);
614
615        assert!(entries.iter().any(|entry| matches!(
616            entry,
617            ProfilePickerEntry::Profile(profile)
618                if candidates[profile.candidate_index].id.as_str() == "my-custom"
619        )));
620        assert!(entries.iter().any(|entry| matches!(
621            entry,
622            ProfilePickerEntry::Header(label) if label.as_ref() == "Custom Profiles"
623        )));
624    }
625
626    #[gpui::test]
627    fn fuzzy_filter_returns_no_results_and_keeps_configure(cx: &mut TestAppContext) {
628        let candidates = vec![ProfileCandidate {
629            id: AgentProfileId("write".into()),
630            name: SharedString::from("Write"),
631            is_builtin: true,
632        }];
633
634        let delegate = ProfilePickerDelegate {
635            fs: FakeFs::new(cx.executor()),
636            provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))),
637            background: cx.executor(),
638            candidates,
639            string_candidates: Arc::new(Vec::new()),
640            filtered_entries: Vec::new(),
641            selected_index: 0,
642            query: String::new(),
643            cancel: None,
644        };
645
646        let matches = Vec::new(); // No matches
647        let _entries = delegate.entries_from_matches(matches);
648    }
649
650    #[gpui::test]
651    fn active_profile_selection_logic_works(cx: &mut TestAppContext) {
652        let candidates = vec![
653            ProfileCandidate {
654                id: AgentProfileId("write".into()),
655                name: SharedString::from("Write"),
656                is_builtin: true,
657            },
658            ProfileCandidate {
659                id: AgentProfileId("ask".into()),
660                name: SharedString::from("Ask"),
661                is_builtin: true,
662            },
663        ];
664
665        let delegate = ProfilePickerDelegate {
666            fs: FakeFs::new(cx.executor()),
667            provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))),
668            background: cx.executor(),
669            candidates,
670            string_candidates: Arc::new(Vec::new()),
671            filtered_entries: vec![
672                ProfilePickerEntry::Profile(ProfileMatchEntry {
673                    candidate_index: 0,
674                    positions: Vec::new(),
675                }),
676                ProfilePickerEntry::Profile(ProfileMatchEntry {
677                    candidate_index: 1,
678                    positions: Vec::new(),
679                }),
680            ],
681            selected_index: 0,
682            query: String::new(),
683            cancel: None,
684        };
685
686        // Active profile should be found at index 0
687        let active_index = delegate.index_of_profile(&AgentProfileId("write".into()));
688        assert_eq!(active_index, Some(0));
689    }
690
691    struct TestProfileProvider {
692        profile_id: AgentProfileId,
693    }
694
695    impl TestProfileProvider {
696        fn new(profile_id: AgentProfileId) -> Self {
697            Self { profile_id }
698        }
699    }
700
701    impl ProfileProvider for TestProfileProvider {
702        fn profile_id(&self, _cx: &App) -> AgentProfileId {
703            self.profile_id.clone()
704        }
705
706        fn set_profile(&self, _profile_id: AgentProfileId, _cx: &mut App) {}
707
708        fn profiles_supported(&self, _cx: &App) -> bool {
709            true
710        }
711    }
712}