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