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