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