profile_selector.rs

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