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