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