1use std::{cmp::Reverse, rc::Rc, sync::Arc};
2
3use acp_thread::AgentSessionConfigOptions;
4use agent_client_protocol as acp;
5use agent_servers::AgentServer;
6use agent_settings::AgentSettings;
7use collections::HashSet;
8use fs::Fs;
9use fuzzy::StringMatchCandidate;
10use gpui::{
11 BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*,
12};
13use ordered_float::OrderedFloat;
14use picker::popover_menu::PickerPopoverMenu;
15use picker::{Picker, PickerDelegate};
16use settings::{Settings, SettingsStore};
17use ui::{
18 DocumentationSide, ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle,
19 Tooltip, prelude::*,
20};
21use util::ResultExt as _;
22
23use crate::ui::HoldForDefault;
24
25const PICKER_THRESHOLD: usize = 5;
26
27pub struct ConfigOptionsView {
28 config_options: Rc<dyn AgentSessionConfigOptions>,
29 selectors: Vec<Entity<ConfigOptionSelector>>,
30 agent_server: Rc<dyn AgentServer>,
31 fs: Arc<dyn Fs>,
32 config_option_ids: Vec<acp::SessionConfigId>,
33 _refresh_task: Task<()>,
34}
35
36impl ConfigOptionsView {
37 pub fn new(
38 config_options: Rc<dyn AgentSessionConfigOptions>,
39 agent_server: Rc<dyn AgentServer>,
40 fs: Arc<dyn Fs>,
41 window: &mut Window,
42 cx: &mut Context<Self>,
43 ) -> Self {
44 let selectors = Self::build_selectors(&config_options, &agent_server, &fs, window, cx);
45 let config_option_ids = Self::config_option_ids(&config_options);
46
47 let rx = config_options.watch(cx);
48 let refresh_task = cx.spawn_in(window, async move |this, cx| {
49 if let Some(mut rx) = rx {
50 while let Ok(()) = rx.recv().await {
51 this.update_in(cx, |this, window, cx| {
52 this.refresh_selectors_if_needed(window, cx);
53 cx.notify();
54 })
55 .log_err();
56 }
57 }
58 });
59
60 Self {
61 config_options,
62 selectors,
63 agent_server,
64 fs,
65 config_option_ids,
66 _refresh_task: refresh_task,
67 }
68 }
69
70 fn config_option_ids(
71 config_options: &Rc<dyn AgentSessionConfigOptions>,
72 ) -> Vec<acp::SessionConfigId> {
73 config_options
74 .config_options()
75 .into_iter()
76 .map(|option| option.id)
77 .collect()
78 }
79
80 fn refresh_selectors_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
81 let current_ids = Self::config_option_ids(&self.config_options);
82 if current_ids != self.config_option_ids {
83 self.config_option_ids = current_ids;
84 self.rebuild_selectors(window, cx);
85 }
86 }
87
88 fn rebuild_selectors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
89 self.selectors = Self::build_selectors(
90 &self.config_options,
91 &self.agent_server,
92 &self.fs,
93 window,
94 cx,
95 );
96 cx.notify();
97 }
98
99 fn build_selectors(
100 config_options: &Rc<dyn AgentSessionConfigOptions>,
101 agent_server: &Rc<dyn AgentServer>,
102 fs: &Arc<dyn Fs>,
103 window: &mut Window,
104 cx: &mut Context<Self>,
105 ) -> Vec<Entity<ConfigOptionSelector>> {
106 config_options
107 .config_options()
108 .into_iter()
109 .map(|option| {
110 let config_options = config_options.clone();
111 let agent_server = agent_server.clone();
112 let fs = fs.clone();
113 cx.new(|cx| {
114 ConfigOptionSelector::new(
115 config_options,
116 option.id.clone(),
117 agent_server,
118 fs,
119 window,
120 cx,
121 )
122 })
123 })
124 .collect()
125 }
126}
127
128impl Render for ConfigOptionsView {
129 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
130 if self.selectors.is_empty() {
131 return div().into_any_element();
132 }
133
134 h_flex()
135 .gap_1()
136 .children(self.selectors.iter().cloned())
137 .into_any_element()
138 }
139}
140
141struct ConfigOptionSelector {
142 config_options: Rc<dyn AgentSessionConfigOptions>,
143 config_id: acp::SessionConfigId,
144 picker_handle: PopoverMenuHandle<Picker<ConfigOptionPickerDelegate>>,
145 picker: Entity<Picker<ConfigOptionPickerDelegate>>,
146 setting_value: bool,
147}
148
149impl ConfigOptionSelector {
150 pub fn new(
151 config_options: Rc<dyn AgentSessionConfigOptions>,
152 config_id: acp::SessionConfigId,
153 agent_server: Rc<dyn AgentServer>,
154 fs: Arc<dyn Fs>,
155 window: &mut Window,
156 cx: &mut Context<Self>,
157 ) -> Self {
158 let option_count = config_options
159 .config_options()
160 .iter()
161 .find(|opt| opt.id == config_id)
162 .map(count_config_options)
163 .unwrap_or(0);
164
165 let is_searchable = option_count >= PICKER_THRESHOLD;
166
167 let picker = {
168 let config_options = config_options.clone();
169 let config_id = config_id.clone();
170 let agent_server = agent_server.clone();
171 let fs = fs.clone();
172 cx.new(move |picker_cx| {
173 let delegate = ConfigOptionPickerDelegate::new(
174 config_options,
175 config_id,
176 agent_server,
177 fs,
178 window,
179 picker_cx,
180 );
181
182 if is_searchable {
183 Picker::list(delegate, window, picker_cx)
184 } else {
185 Picker::nonsearchable_list(delegate, window, picker_cx)
186 }
187 .show_scrollbar(true)
188 .width(rems(20.))
189 .max_height(Some(rems(20.).into()))
190 })
191 };
192
193 Self {
194 config_options,
195 config_id,
196 picker_handle: PopoverMenuHandle::default(),
197 picker,
198 setting_value: false,
199 }
200 }
201
202 fn current_option(&self) -> Option<acp::SessionConfigOption> {
203 self.config_options
204 .config_options()
205 .into_iter()
206 .find(|opt| opt.id == self.config_id)
207 }
208
209 fn current_value_name(&self) -> String {
210 let Some(option) = self.current_option() else {
211 return "Unknown".to_string();
212 };
213
214 match &option.kind {
215 acp::SessionConfigKind::Select(select) => {
216 find_option_name(&select.options, &select.current_value)
217 .unwrap_or_else(|| "Unknown".to_string())
218 }
219 _ => "Unknown".to_string(),
220 }
221 }
222
223 fn render_trigger_button(&self, _window: &mut Window, _cx: &mut Context<Self>) -> Button {
224 let Some(option) = self.current_option() else {
225 return Button::new("config-option-trigger", "Unknown")
226 .label_size(LabelSize::Small)
227 .color(Color::Muted)
228 .disabled(true);
229 };
230
231 let icon = if self.picker_handle.is_deployed() {
232 IconName::ChevronUp
233 } else {
234 IconName::ChevronDown
235 };
236
237 Button::new(
238 ElementId::Name(format!("config-option-{}", option.id.0).into()),
239 self.current_value_name(),
240 )
241 .label_size(LabelSize::Small)
242 .color(Color::Muted)
243 .icon(icon)
244 .icon_size(IconSize::XSmall)
245 .icon_position(IconPosition::End)
246 .icon_color(Color::Muted)
247 .disabled(self.setting_value)
248 }
249}
250
251impl Render for ConfigOptionSelector {
252 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
253 let Some(option) = self.current_option() else {
254 return div().into_any_element();
255 };
256
257 let trigger_button = self.render_trigger_button(window, cx);
258
259 let option_name = option.name.clone();
260 let option_description: Option<SharedString> = option.description.map(Into::into);
261
262 let tooltip = Tooltip::element(move |_window, _cx| {
263 let mut content = v_flex().gap_1().child(Label::new(option_name.clone()));
264 if let Some(desc) = option_description.as_ref() {
265 content = content.child(
266 Label::new(desc.clone())
267 .size(LabelSize::Small)
268 .color(Color::Muted),
269 );
270 }
271 content.into_any()
272 });
273
274 PickerPopoverMenu::new(
275 self.picker.clone(),
276 trigger_button,
277 tooltip,
278 gpui::Corner::BottomRight,
279 cx,
280 )
281 .with_handle(self.picker_handle.clone())
282 .render(window, cx)
283 .into_any_element()
284 }
285}
286
287#[derive(Clone)]
288enum ConfigOptionPickerEntry {
289 Separator(SharedString),
290 Option(ConfigOptionValue),
291}
292
293#[derive(Clone)]
294struct ConfigOptionValue {
295 value: acp::SessionConfigValueId,
296 name: String,
297 description: Option<String>,
298 group: Option<String>,
299}
300
301struct ConfigOptionPickerDelegate {
302 config_options: Rc<dyn AgentSessionConfigOptions>,
303 config_id: acp::SessionConfigId,
304 agent_server: Rc<dyn AgentServer>,
305 fs: Arc<dyn Fs>,
306 filtered_entries: Vec<ConfigOptionPickerEntry>,
307 all_options: Vec<ConfigOptionValue>,
308 selected_index: usize,
309 selected_description: Option<(usize, SharedString, bool)>,
310 favorites: HashSet<acp::SessionConfigValueId>,
311 _settings_subscription: Subscription,
312}
313
314impl ConfigOptionPickerDelegate {
315 fn new(
316 config_options: Rc<dyn AgentSessionConfigOptions>,
317 config_id: acp::SessionConfigId,
318 agent_server: Rc<dyn AgentServer>,
319 fs: Arc<dyn Fs>,
320 window: &mut Window,
321 cx: &mut Context<Picker<Self>>,
322 ) -> Self {
323 let favorites = agent_server.favorite_config_option_value_ids(&config_id, cx);
324
325 let all_options = extract_options(&config_options, &config_id);
326 let filtered_entries = options_to_picker_entries(&all_options, &favorites);
327
328 let current_value = get_current_value(&config_options, &config_id);
329 let selected_index = current_value
330 .and_then(|current| {
331 filtered_entries.iter().position(|entry| {
332 matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
333 })
334 })
335 .unwrap_or(0);
336
337 let agent_server_for_subscription = agent_server.clone();
338 let config_id_for_subscription = config_id.clone();
339 let settings_subscription =
340 cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
341 let new_favorites = agent_server_for_subscription
342 .favorite_config_option_value_ids(&config_id_for_subscription, cx);
343 if new_favorites != picker.delegate.favorites {
344 picker.delegate.favorites = new_favorites;
345 picker.refresh(window, cx);
346 }
347 });
348
349 cx.notify();
350
351 Self {
352 config_options,
353 config_id,
354 agent_server,
355 fs,
356 filtered_entries,
357 all_options,
358 selected_index,
359 selected_description: None,
360 favorites,
361 _settings_subscription: settings_subscription,
362 }
363 }
364
365 fn current_value(&self) -> Option<acp::SessionConfigValueId> {
366 get_current_value(&self.config_options, &self.config_id)
367 }
368}
369
370impl PickerDelegate for ConfigOptionPickerDelegate {
371 type ListItem = AnyElement;
372
373 fn match_count(&self) -> usize {
374 self.filtered_entries.len()
375 }
376
377 fn selected_index(&self) -> usize {
378 self.selected_index
379 }
380
381 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
382 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
383 cx.notify();
384 }
385
386 fn can_select(
387 &mut self,
388 ix: usize,
389 _window: &mut Window,
390 _cx: &mut Context<Picker<Self>>,
391 ) -> bool {
392 match self.filtered_entries.get(ix) {
393 Some(ConfigOptionPickerEntry::Option(_)) => true,
394 Some(ConfigOptionPickerEntry::Separator(_)) | None => false,
395 }
396 }
397
398 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
399 "Select an option…".into()
400 }
401
402 fn update_matches(
403 &mut self,
404 query: String,
405 window: &mut Window,
406 cx: &mut Context<Picker<Self>>,
407 ) -> Task<()> {
408 let all_options = self.all_options.clone();
409
410 cx.spawn_in(window, async move |this, cx| {
411 let filtered_options = match this
412 .read_with(cx, |_, cx| {
413 if query.is_empty() {
414 None
415 } else {
416 Some((all_options.clone(), query.clone(), cx.background_executor().clone()))
417 }
418 })
419 .ok()
420 .flatten()
421 {
422 Some((options, q, executor)) => fuzzy_search_options(options, &q, executor).await,
423 None => all_options,
424 };
425
426 this.update_in(cx, |this, window, cx| {
427 this.delegate.filtered_entries =
428 options_to_picker_entries(&filtered_options, &this.delegate.favorites);
429
430 let current_value = this.delegate.current_value();
431 let new_index = current_value
432 .and_then(|current| {
433 this.delegate.filtered_entries.iter().position(|entry| {
434 matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
435 })
436 })
437 .unwrap_or(0);
438
439 this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
440 cx.notify();
441 })
442 .ok();
443 })
444 }
445
446 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
447 if let Some(ConfigOptionPickerEntry::Option(option)) =
448 self.filtered_entries.get(self.selected_index)
449 {
450 if window.modifiers().secondary() {
451 let default_value = self
452 .agent_server
453 .default_config_option(self.config_id.0.as_ref(), cx);
454 let is_default = default_value.as_deref() == Some(&*option.value.0);
455
456 self.agent_server.set_default_config_option(
457 self.config_id.0.as_ref(),
458 if is_default {
459 None
460 } else {
461 Some(option.value.0.as_ref())
462 },
463 self.fs.clone(),
464 cx,
465 );
466 }
467
468 let task = self.config_options.set_config_option(
469 self.config_id.clone(),
470 option.value.clone(),
471 cx,
472 );
473
474 cx.spawn(async move |_, _| {
475 if let Err(err) = task.await {
476 log::error!("Failed to set config option: {:?}", err);
477 }
478 })
479 .detach();
480
481 cx.emit(DismissEvent);
482 }
483 }
484
485 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
486 cx.defer_in(window, |picker, window, cx| {
487 picker.set_query("", window, cx);
488 });
489 }
490
491 fn render_match(
492 &self,
493 ix: usize,
494 selected: bool,
495 _: &mut Window,
496 cx: &mut Context<Picker<Self>>,
497 ) -> Option<Self::ListItem> {
498 match self.filtered_entries.get(ix)? {
499 ConfigOptionPickerEntry::Separator(title) => Some(
500 div()
501 .when(ix > 0, |this| this.mt_1())
502 .child(
503 div()
504 .px_2()
505 .py_1()
506 .text_xs()
507 .text_color(cx.theme().colors().text_muted)
508 .child(title.clone()),
509 )
510 .into_any_element(),
511 ),
512 ConfigOptionPickerEntry::Option(option) => {
513 let current_value = self.current_value();
514 let is_selected = current_value.as_ref() == Some(&option.value);
515
516 let default_value = self
517 .agent_server
518 .default_config_option(self.config_id.0.as_ref(), cx);
519 let is_default = default_value.as_deref() == Some(&*option.value.0);
520
521 let is_favorite = self.favorites.contains(&option.value);
522
523 let option_name = option.name.clone();
524 let description = option.description.clone();
525
526 Some(
527 div()
528 .id(("config-option-picker-item", ix))
529 .when_some(description, |this, desc| {
530 let desc: SharedString = desc.into();
531 this.on_hover(cx.listener(move |menu, hovered, _, cx| {
532 if *hovered {
533 menu.delegate.selected_description =
534 Some((ix, desc.clone(), is_default));
535 } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix)
536 {
537 menu.delegate.selected_description = None;
538 }
539 cx.notify();
540 }))
541 })
542 .child(
543 ListItem::new(ix)
544 .inset(true)
545 .spacing(ListItemSpacing::Sparse)
546 .toggle_state(selected)
547 .child(h_flex().w_full().child(Label::new(option_name).truncate()))
548 .end_slot(div().pr_2().when(is_selected, |this| {
549 this.child(Icon::new(IconName::Check).color(Color::Accent))
550 }))
551 .end_hover_slot(div().pr_1p5().child({
552 let (icon, color, tooltip) = if is_favorite {
553 (IconName::StarFilled, Color::Accent, "Unfavorite")
554 } else {
555 (IconName::Star, Color::Default, "Favorite")
556 };
557
558 let config_id = self.config_id.clone();
559 let value_id = option.value.clone();
560 let agent_server = self.agent_server.clone();
561 let fs = self.fs.clone();
562
563 IconButton::new(("toggle-favorite-config-option", ix), icon)
564 .layer(ElevationIndex::ElevatedSurface)
565 .icon_color(color)
566 .icon_size(IconSize::Small)
567 .tooltip(Tooltip::text(tooltip))
568 .on_click(move |_, _, cx| {
569 agent_server.toggle_favorite_config_option_value(
570 config_id.clone(),
571 value_id.clone(),
572 !is_favorite,
573 fs.clone(),
574 cx,
575 );
576 })
577 })),
578 )
579 .into_any_element(),
580 )
581 }
582 }
583 }
584
585 fn documentation_aside(
586 &self,
587 _window: &mut Window,
588 cx: &mut Context<Picker<Self>>,
589 ) -> Option<ui::DocumentationAside> {
590 self.selected_description
591 .as_ref()
592 .map(|(_, description, is_default)| {
593 let description = description.clone();
594 let is_default = *is_default;
595
596 let settings = AgentSettings::get_global(cx);
597 let side = match settings.dock {
598 settings::DockPosition::Left => DocumentationSide::Right,
599 settings::DockPosition::Bottom | settings::DockPosition::Right => {
600 DocumentationSide::Left
601 }
602 };
603
604 ui::DocumentationAside::new(
605 side,
606 Rc::new(move |_| {
607 v_flex()
608 .gap_1()
609 .child(Label::new(description.clone()))
610 .child(HoldForDefault::new(is_default))
611 .into_any_element()
612 }),
613 )
614 })
615 }
616
617 fn documentation_aside_index(&self) -> Option<usize> {
618 self.selected_description.as_ref().map(|(ix, _, _)| *ix)
619 }
620}
621
622fn extract_options(
623 config_options: &Rc<dyn AgentSessionConfigOptions>,
624 config_id: &acp::SessionConfigId,
625) -> Vec<ConfigOptionValue> {
626 let Some(option) = config_options
627 .config_options()
628 .into_iter()
629 .find(|opt| &opt.id == config_id)
630 else {
631 return Vec::new();
632 };
633
634 match &option.kind {
635 acp::SessionConfigKind::Select(select) => match &select.options {
636 acp::SessionConfigSelectOptions::Ungrouped(options) => options
637 .iter()
638 .map(|opt| ConfigOptionValue {
639 value: opt.value.clone(),
640 name: opt.name.clone(),
641 description: opt.description.clone(),
642 group: None,
643 })
644 .collect(),
645 acp::SessionConfigSelectOptions::Grouped(groups) => groups
646 .iter()
647 .flat_map(|group| {
648 group.options.iter().map(|opt| ConfigOptionValue {
649 value: opt.value.clone(),
650 name: opt.name.clone(),
651 description: opt.description.clone(),
652 group: Some(group.name.clone()),
653 })
654 })
655 .collect(),
656 _ => Vec::new(),
657 },
658 _ => Vec::new(),
659 }
660}
661
662fn get_current_value(
663 config_options: &Rc<dyn AgentSessionConfigOptions>,
664 config_id: &acp::SessionConfigId,
665) -> Option<acp::SessionConfigValueId> {
666 config_options
667 .config_options()
668 .into_iter()
669 .find(|opt| &opt.id == config_id)
670 .and_then(|opt| match &opt.kind {
671 acp::SessionConfigKind::Select(select) => Some(select.current_value.clone()),
672 _ => None,
673 })
674}
675
676fn options_to_picker_entries(
677 options: &[ConfigOptionValue],
678 favorites: &HashSet<acp::SessionConfigValueId>,
679) -> Vec<ConfigOptionPickerEntry> {
680 let mut entries = Vec::new();
681
682 let mut favorite_options = Vec::new();
683
684 for option in options {
685 if favorites.contains(&option.value) {
686 favorite_options.push(option.clone());
687 }
688 }
689
690 if !favorite_options.is_empty() {
691 entries.push(ConfigOptionPickerEntry::Separator("Favorites".into()));
692 for option in favorite_options {
693 entries.push(ConfigOptionPickerEntry::Option(option));
694 }
695
696 // If the remaining list would start ungrouped (group == None), insert a separator so
697 // Favorites doesn't visually run into the main list.
698 if let Some(option) = options.first()
699 && option.group.is_none()
700 {
701 entries.push(ConfigOptionPickerEntry::Separator("All Options".into()));
702 }
703 }
704
705 let mut current_group: Option<String> = None;
706 for option in options {
707 if option.group != current_group {
708 if let Some(group_name) = &option.group {
709 entries.push(ConfigOptionPickerEntry::Separator(
710 group_name.clone().into(),
711 ));
712 }
713 current_group = option.group.clone();
714 }
715 entries.push(ConfigOptionPickerEntry::Option(option.clone()));
716 }
717
718 entries
719}
720
721async fn fuzzy_search_options(
722 options: Vec<ConfigOptionValue>,
723 query: &str,
724 executor: BackgroundExecutor,
725) -> Vec<ConfigOptionValue> {
726 let candidates = options
727 .iter()
728 .enumerate()
729 .map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name))
730 .collect::<Vec<_>>();
731
732 let mut matches = fuzzy::match_strings(
733 &candidates,
734 query,
735 false,
736 true,
737 100,
738 &Default::default(),
739 executor,
740 )
741 .await;
742
743 matches.sort_unstable_by_key(|mat| {
744 let candidate = &candidates[mat.candidate_id];
745 (Reverse(OrderedFloat(mat.score)), candidate.id)
746 });
747
748 matches
749 .into_iter()
750 .map(|mat| options[mat.candidate_id].clone())
751 .collect()
752}
753
754fn find_option_name(
755 options: &acp::SessionConfigSelectOptions,
756 value_id: &acp::SessionConfigValueId,
757) -> Option<String> {
758 match options {
759 acp::SessionConfigSelectOptions::Ungrouped(opts) => opts
760 .iter()
761 .find(|o| &o.value == value_id)
762 .map(|o| o.name.clone()),
763 acp::SessionConfigSelectOptions::Grouped(groups) => groups.iter().find_map(|group| {
764 group
765 .options
766 .iter()
767 .find(|o| &o.value == value_id)
768 .map(|o| o.name.clone())
769 }),
770 _ => None,
771 }
772}
773
774fn count_config_options(option: &acp::SessionConfigOption) -> usize {
775 match &option.kind {
776 acp::SessionConfigKind::Select(select) => match &select.options {
777 acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(),
778 acp::SessionConfigSelectOptions::Grouped(groups) => {
779 groups.iter().map(|g| g.options.len()).sum()
780 }
781 _ => 0,
782 },
783 _ => 0,
784 }
785}