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