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