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