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