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