config_options.rs

  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}