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