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