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