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