Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:57:40.832Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

profile_selector.rs

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