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