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