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