profile_selector.rs

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