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