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