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