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, Empty, 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    /// Check if there is a model selected in the current context.
 36    fn model_selected(&self, cx: &App) -> bool;
 37}
 38
 39pub struct ProfileSelector {
 40    profiles: AvailableProfiles,
 41    pending_refresh: bool,
 42    fs: Arc<dyn Fs>,
 43    provider: Arc<dyn ProfileProvider>,
 44    picker: Option<Entity<Picker<ProfilePickerDelegate>>>,
 45    picker_handle: PopoverMenuHandle<Picker<ProfilePickerDelegate>>,
 46    focus_handle: FocusHandle,
 47    _subscriptions: Vec<Subscription>,
 48}
 49
 50impl ProfileSelector {
 51    pub fn new(
 52        fs: Arc<dyn Fs>,
 53        provider: Arc<dyn ProfileProvider>,
 54        focus_handle: FocusHandle,
 55        cx: &mut Context<Self>,
 56    ) -> Self {
 57        let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
 58            this.pending_refresh = true;
 59            cx.notify();
 60        });
 61
 62        Self {
 63            profiles: AgentProfile::available_profiles(cx),
 64            pending_refresh: false,
 65            fs,
 66            provider,
 67            picker: None,
 68            picker_handle: PopoverMenuHandle::default(),
 69            focus_handle,
 70            _subscriptions: vec![settings_subscription],
 71        }
 72    }
 73
 74    pub fn menu_handle(&self) -> PopoverMenuHandle<Picker<ProfilePickerDelegate>> {
 75        self.picker_handle.clone()
 76    }
 77
 78    pub fn cycle_profile(&mut self, cx: &mut Context<Self>) {
 79        if !self.provider.profiles_supported(cx) {
 80            return;
 81        }
 82
 83        let profiles = AgentProfile::available_profiles(cx);
 84        if profiles.is_empty() {
 85            return;
 86        }
 87
 88        let current_profile_id = self.provider.profile_id(cx);
 89        let current_index = profiles
 90            .keys()
 91            .position(|id| id == &current_profile_id)
 92            .unwrap_or(0);
 93
 94        let next_index = (current_index + 1) % profiles.len();
 95
 96        if let Some((next_profile_id, _)) = profiles.get_index(next_index) {
 97            self.provider.set_profile(next_profile_id.clone(), cx);
 98            cx.notify();
 99        }
100    }
101
102    fn ensure_picker(
103        &mut self,
104        window: &mut Window,
105        cx: &mut Context<Self>,
106    ) -> Entity<Picker<ProfilePickerDelegate>> {
107        if self.picker.is_none() {
108            let delegate = ProfilePickerDelegate::new(
109                self.fs.clone(),
110                self.provider.clone(),
111                self.profiles.clone(),
112                cx.foreground_executor().clone(),
113                cx.background_executor().clone(),
114                self.focus_handle.clone(),
115                cx,
116            );
117
118            let picker = cx.new(|cx| {
119                Picker::list(delegate, window, cx)
120                    .show_scrollbar(true)
121                    .width(rems(18.))
122                    .max_height(Some(rems(20.).into()))
123            });
124
125            self.picker = Some(picker);
126        }
127
128        if self.pending_refresh {
129            if let Some(picker) = &self.picker {
130                let profiles = AgentProfile::available_profiles(cx);
131                self.profiles = profiles.clone();
132                picker.update(cx, |picker, cx| {
133                    let query = picker.query(cx);
134                    picker
135                        .delegate
136                        .refresh_profiles(profiles.clone(), query, cx);
137                });
138            }
139            self.pending_refresh = false;
140        }
141
142        self.picker.as_ref().unwrap().clone()
143    }
144}
145
146impl Focusable for ProfileSelector {
147    fn focus_handle(&self, cx: &App) -> FocusHandle {
148        if let Some(picker) = &self.picker {
149            picker.focus_handle(cx)
150        } else {
151            self.focus_handle.clone()
152        }
153    }
154}
155
156impl Render for ProfileSelector {
157    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
158        if !self.provider.model_selected(cx) {
159            return Empty.into_any_element();
160        }
161
162        if !self.provider.profiles_supported(cx) {
163            return Button::new("tools-not-supported-button", "Tools Unsupported")
164                .disabled(true)
165                .label_size(LabelSize::Small)
166                .color(Color::Muted)
167                .tooltip(Tooltip::text("This model does not support tools."))
168                .into_any_element();
169        }
170
171        let picker = self.ensure_picker(window, cx);
172
173        let settings = AgentSettings::get_global(cx);
174        let profile_id = self.provider.profile_id(cx);
175        let profile = settings.profiles.get(&profile_id);
176
177        let selected_profile = profile
178            .map(|profile| profile.name.clone())
179            .unwrap_or_else(|| "Unknown".into());
180
181        let icon = if self.picker_handle.is_deployed() {
182            IconName::ChevronUp
183        } else {
184            IconName::ChevronDown
185        };
186
187        let trigger_button = Button::new("profile-selector", selected_profile)
188            .label_size(LabelSize::Small)
189            .color(Color::Muted)
190            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
191
192        let tooltip: Box<dyn Fn(&mut Window, &mut App) -> AnyView> = Box::new(Tooltip::element({
193            move |_window, cx| {
194                let container = || h_flex().gap_1().justify_between();
195                v_flex()
196                    .gap_1()
197                    .child(
198                        container()
199                            .child(Label::new("Change Profile"))
200                            .child(KeyBinding::for_action(&ToggleProfileSelector, cx)),
201                    )
202                    .child(
203                        container()
204                            .pt_1()
205                            .border_t_1()
206                            .border_color(cx.theme().colors().border_variant)
207                            .child(Label::new("Cycle Through Profiles"))
208                            .child(KeyBinding::for_action(&CycleModeSelector, cx)),
209                    )
210                    .into_any()
211            }
212        }));
213
214        PickerPopoverMenu::new(
215            picker,
216            trigger_button,
217            tooltip,
218            gpui::Corner::BottomRight,
219            cx,
220        )
221        .with_handle(self.picker_handle.clone())
222        .render(window, cx)
223        .into_any_element()
224    }
225}
226
227#[derive(Clone)]
228struct ProfileCandidate {
229    id: AgentProfileId,
230    name: SharedString,
231    is_builtin: bool,
232}
233
234#[derive(Clone)]
235struct ProfileMatchEntry {
236    candidate_index: usize,
237    positions: Vec<usize>,
238}
239
240enum ProfilePickerEntry {
241    Header(SharedString),
242    Profile(ProfileMatchEntry),
243}
244
245pub struct ProfilePickerDelegate {
246    fs: Arc<dyn Fs>,
247    provider: Arc<dyn ProfileProvider>,
248    foreground: ForegroundExecutor,
249    background: BackgroundExecutor,
250    candidates: Vec<ProfileCandidate>,
251    string_candidates: Arc<Vec<StringMatchCandidate>>,
252    filtered_entries: Vec<ProfilePickerEntry>,
253    selected_index: usize,
254    hovered_index: Option<usize>,
255    query: String,
256    cancel: Option<Arc<AtomicBool>>,
257    focus_handle: FocusHandle,
258}
259
260impl ProfilePickerDelegate {
261    fn new(
262        fs: Arc<dyn Fs>,
263        provider: Arc<dyn ProfileProvider>,
264        profiles: AvailableProfiles,
265        foreground: ForegroundExecutor,
266        background: BackgroundExecutor,
267        focus_handle: FocusHandle,
268        cx: &mut Context<ProfileSelector>,
269    ) -> Self {
270        let candidates = Self::candidates_from(profiles);
271        let string_candidates = Arc::new(Self::string_candidates(&candidates));
272        let filtered_entries = Self::entries_from_candidates(&candidates);
273
274        let mut this = Self {
275            fs,
276            provider,
277            foreground,
278            background,
279            candidates,
280            string_candidates,
281            filtered_entries,
282            selected_index: 0,
283            hovered_index: None,
284            query: String::new(),
285            cancel: None,
286            focus_handle,
287        };
288
289        this.selected_index = this
290            .index_of_profile(&this.provider.profile_id(cx))
291            .unwrap_or_else(|| this.first_selectable_index().unwrap_or(0));
292
293        this
294    }
295
296    fn refresh_profiles(
297        &mut self,
298        profiles: AvailableProfiles,
299        query: String,
300        cx: &mut Context<Picker<Self>>,
301    ) {
302        self.candidates = Self::candidates_from(profiles);
303        self.string_candidates = Arc::new(Self::string_candidates(&self.candidates));
304        self.query = query;
305
306        if self.query.is_empty() {
307            self.filtered_entries = Self::entries_from_candidates(&self.candidates);
308        } else {
309            let matches = self.search_blocking(&self.query);
310            self.filtered_entries = self.entries_from_matches(matches);
311        }
312
313        self.selected_index = self
314            .index_of_profile(&self.provider.profile_id(cx))
315            .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0));
316        cx.notify();
317    }
318
319    fn candidates_from(profiles: AvailableProfiles) -> Vec<ProfileCandidate> {
320        profiles
321            .into_iter()
322            .map(|(id, name)| ProfileCandidate {
323                is_builtin: builtin_profiles::is_builtin(&id),
324                id,
325                name,
326            })
327            .collect()
328    }
329
330    fn string_candidates(candidates: &[ProfileCandidate]) -> Vec<StringMatchCandidate> {
331        candidates
332            .iter()
333            .enumerate()
334            .map(|(index, candidate)| StringMatchCandidate::new(index, candidate.name.as_ref()))
335            .collect()
336    }
337
338    fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> {
339        match candidate.id.as_str() {
340            builtin_profiles::WRITE => Some("Get help to write anything."),
341            builtin_profiles::ASK => Some("Chat about your codebase."),
342            builtin_profiles::MINIMAL => Some("Chat about anything with no tools."),
343            _ => None,
344        }
345    }
346
347    fn entries_from_candidates(candidates: &[ProfileCandidate]) -> Vec<ProfilePickerEntry> {
348        let mut entries = Vec::new();
349        let mut inserted_custom_header = false;
350
351        for (idx, candidate) in candidates.iter().enumerate() {
352            if !candidate.is_builtin && !inserted_custom_header {
353                if !entries.is_empty() {
354                    entries.push(ProfilePickerEntry::Header("Custom Profiles".into()));
355                }
356                inserted_custom_header = true;
357            }
358
359            entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
360                candidate_index: idx,
361                positions: Vec::new(),
362            }));
363        }
364
365        entries
366    }
367
368    fn entries_from_matches(&self, matches: Vec<StringMatch>) -> Vec<ProfilePickerEntry> {
369        let mut entries = Vec::new();
370        for mat in matches {
371            if self.candidates.get(mat.candidate_id).is_some() {
372                entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
373                    candidate_index: mat.candidate_id,
374                    positions: mat.positions,
375                }));
376            }
377        }
378        entries
379    }
380
381    fn first_selectable_index(&self) -> Option<usize> {
382        self.filtered_entries
383            .iter()
384            .position(|entry| matches!(entry, ProfilePickerEntry::Profile(_)))
385    }
386
387    fn index_of_profile(&self, profile_id: &AgentProfileId) -> Option<usize> {
388        self.filtered_entries.iter().position(|entry| {
389            matches!(entry, ProfilePickerEntry::Profile(profile) if self
390                .candidates
391                .get(profile.candidate_index)
392                .map(|candidate| &candidate.id == profile_id)
393                .unwrap_or(false))
394        })
395    }
396
397    fn search_blocking(&self, query: &str) -> Vec<StringMatch> {
398        if query.is_empty() {
399            return self
400                .string_candidates
401                .iter()
402                .map(|candidate| StringMatch {
403                    candidate_id: candidate.id,
404                    score: 0.0,
405                    positions: Vec::new(),
406                    string: candidate.string.clone(),
407                })
408                .collect();
409        }
410
411        let cancel_flag = AtomicBool::new(false);
412
413        self.foreground.block_on(match_strings(
414            self.string_candidates.as_ref(),
415            query,
416            false,
417            true,
418            100,
419            &cancel_flag,
420            self.background.clone(),
421        ))
422    }
423}
424
425impl PickerDelegate for ProfilePickerDelegate {
426    type ListItem = AnyElement;
427
428    fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc<str> {
429        "Search profiles…".into()
430    }
431
432    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
433        let text = if self.candidates.is_empty() {
434            "No profiles.".into()
435        } else {
436            "No profiles match your search.".into()
437        };
438        Some(text)
439    }
440
441    fn match_count(&self) -> usize {
442        self.filtered_entries.len()
443    }
444
445    fn selected_index(&self) -> usize {
446        self.selected_index
447    }
448
449    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
450        self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
451        cx.notify();
452    }
453
454    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> 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 side = documentation_aside_side(cx);
642
643        Some(DocumentationAside {
644            side,
645            render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()),
646        })
647    }
648
649    fn documentation_aside_index(&self) -> Option<usize> {
650        self.hovered_index
651    }
652
653    fn render_footer(
654        &self,
655        _: &mut Window,
656        cx: &mut Context<Picker<Self>>,
657    ) -> Option<gpui::AnyElement> {
658        let focus_handle = self.focus_handle.clone();
659
660        Some(
661            h_flex()
662                .w_full()
663                .border_t_1()
664                .border_color(cx.theme().colors().border_variant)
665                .p_1p5()
666                .child(
667                    Button::new("configure", "Configure")
668                        .full_width()
669                        .style(ButtonStyle::Outlined)
670                        .key_binding(
671                            KeyBinding::for_action_in(
672                                &ManageProfiles::default(),
673                                &focus_handle,
674                                cx,
675                            )
676                            .map(|kb| kb.size(rems_from_px(12.))),
677                        )
678                        .on_click(|_, window, cx| {
679                            window.dispatch_action(ManageProfiles::default().boxed_clone(), cx);
680                        }),
681                )
682                .into_any(),
683        )
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use fs::FakeFs;
691    use gpui::TestAppContext;
692
693    #[gpui::test]
694    fn entries_include_custom_profiles(_cx: &mut TestAppContext) {
695        let candidates = vec![
696            ProfileCandidate {
697                id: AgentProfileId("write".into()),
698                name: SharedString::from("Write"),
699                is_builtin: true,
700            },
701            ProfileCandidate {
702                id: AgentProfileId("my-custom".into()),
703                name: SharedString::from("My Custom"),
704                is_builtin: false,
705            },
706        ];
707
708        let entries = ProfilePickerDelegate::entries_from_candidates(&candidates);
709
710        assert!(entries.iter().any(|entry| matches!(
711            entry,
712            ProfilePickerEntry::Profile(profile)
713                if candidates[profile.candidate_index].id.as_str() == "my-custom"
714        )));
715        assert!(entries.iter().any(|entry| matches!(
716            entry,
717            ProfilePickerEntry::Header(label) if label.as_ref() == "Custom Profiles"
718        )));
719    }
720
721    #[gpui::test]
722    fn fuzzy_filter_returns_no_results_and_keeps_configure(cx: &mut TestAppContext) {
723        let candidates = vec![ProfileCandidate {
724            id: AgentProfileId("write".into()),
725            name: SharedString::from("Write"),
726            is_builtin: true,
727        }];
728
729        cx.update(|cx| {
730            let focus_handle = cx.focus_handle();
731
732            let delegate = ProfilePickerDelegate {
733                fs: FakeFs::new(cx.background_executor().clone()),
734                provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))),
735                foreground: cx.foreground_executor().clone(),
736                background: cx.background_executor().clone(),
737                candidates,
738                string_candidates: Arc::new(Vec::new()),
739                filtered_entries: Vec::new(),
740                selected_index: 0,
741                hovered_index: None,
742                query: String::new(),
743                cancel: None,
744                focus_handle,
745            };
746
747            let matches = Vec::new(); // No matches
748            let _entries = delegate.entries_from_matches(matches);
749        });
750    }
751
752    #[gpui::test]
753    fn active_profile_selection_logic_works(cx: &mut TestAppContext) {
754        let candidates = vec![
755            ProfileCandidate {
756                id: AgentProfileId("write".into()),
757                name: SharedString::from("Write"),
758                is_builtin: true,
759            },
760            ProfileCandidate {
761                id: AgentProfileId("ask".into()),
762                name: SharedString::from("Ask"),
763                is_builtin: true,
764            },
765        ];
766
767        cx.update(|cx| {
768            let focus_handle = cx.focus_handle();
769
770            let delegate = ProfilePickerDelegate {
771                fs: FakeFs::new(cx.background_executor().clone()),
772                provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))),
773                foreground: cx.foreground_executor().clone(),
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        has_model: bool,
803    }
804
805    impl TestProfileProvider {
806        fn new(profile_id: AgentProfileId) -> Self {
807            Self {
808                profile_id,
809                has_model: true,
810            }
811        }
812    }
813
814    impl ProfileProvider for TestProfileProvider {
815        fn profile_id(&self, _cx: &App) -> AgentProfileId {
816            self.profile_id.clone()
817        }
818
819        fn set_profile(&self, _profile_id: AgentProfileId, _cx: &mut App) {}
820
821        fn profiles_supported(&self, _cx: &App) -> bool {
822            true
823        }
824
825        fn model_selected(&self, _cx: &App) -> bool {
826            self.has_model
827        }
828    }
829}