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