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        .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
354        .disabled(self.setting_value)
355    }
356}
357
358impl Render for ConfigOptionSelector {
359    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
360        let Some(option) = self.current_option() else {
361            return div().into_any_element();
362        };
363
364        let trigger_button = self.render_trigger_button(window, cx);
365
366        let option_name = option.name.clone();
367        let option_description: Option<SharedString> = option.description.map(Into::into);
368
369        let tooltip = Tooltip::element(move |_window, _cx| {
370            let mut content = v_flex().gap_1().child(Label::new(option_name.clone()));
371            if let Some(desc) = option_description.as_ref() {
372                content = content.child(
373                    Label::new(desc.clone())
374                        .size(LabelSize::Small)
375                        .color(Color::Muted),
376                );
377            }
378            content.into_any()
379        });
380
381        PickerPopoverMenu::new(
382            self.picker.clone(),
383            trigger_button,
384            tooltip,
385            gpui::Corner::BottomRight,
386            cx,
387        )
388        .with_handle(self.picker_handle.clone())
389        .render(window, cx)
390        .into_any_element()
391    }
392}
393
394#[derive(Clone)]
395enum ConfigOptionPickerEntry {
396    Separator(SharedString),
397    Option(ConfigOptionValue),
398}
399
400#[derive(Clone)]
401struct ConfigOptionValue {
402    value: acp::SessionConfigValueId,
403    name: String,
404    description: Option<String>,
405    group: Option<String>,
406}
407
408struct ConfigOptionPickerDelegate {
409    config_options: Rc<dyn AgentSessionConfigOptions>,
410    config_id: acp::SessionConfigId,
411    agent_server: Rc<dyn AgentServer>,
412    fs: Arc<dyn Fs>,
413    filtered_entries: Vec<ConfigOptionPickerEntry>,
414    all_options: Vec<ConfigOptionValue>,
415    selected_index: usize,
416    selected_description: Option<(usize, SharedString, bool)>,
417    favorites: HashSet<acp::SessionConfigValueId>,
418    _settings_subscription: Subscription,
419}
420
421impl ConfigOptionPickerDelegate {
422    fn new(
423        config_options: Rc<dyn AgentSessionConfigOptions>,
424        config_id: acp::SessionConfigId,
425        agent_server: Rc<dyn AgentServer>,
426        fs: Arc<dyn Fs>,
427        window: &mut Window,
428        cx: &mut Context<Picker<Self>>,
429    ) -> Self {
430        let favorites = agent_server.favorite_config_option_value_ids(&config_id, cx);
431
432        let all_options = extract_options(&config_options, &config_id);
433        let filtered_entries = options_to_picker_entries(&all_options, &favorites);
434
435        let current_value = get_current_value(&config_options, &config_id);
436        let selected_index = current_value
437            .and_then(|current| {
438                filtered_entries.iter().position(|entry| {
439                    matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
440                })
441            })
442            .unwrap_or(0);
443
444        let agent_server_for_subscription = agent_server.clone();
445        let config_id_for_subscription = config_id.clone();
446        let settings_subscription =
447            cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
448                let new_favorites = agent_server_for_subscription
449                    .favorite_config_option_value_ids(&config_id_for_subscription, cx);
450                if new_favorites != picker.delegate.favorites {
451                    picker.delegate.favorites = new_favorites;
452                    picker.refresh(window, cx);
453                }
454            });
455
456        cx.notify();
457
458        Self {
459            config_options,
460            config_id,
461            agent_server,
462            fs,
463            filtered_entries,
464            all_options,
465            selected_index,
466            selected_description: None,
467            favorites,
468            _settings_subscription: settings_subscription,
469        }
470    }
471
472    fn current_value(&self) -> Option<acp::SessionConfigValueId> {
473        get_current_value(&self.config_options, &self.config_id)
474    }
475}
476
477impl PickerDelegate for ConfigOptionPickerDelegate {
478    type ListItem = AnyElement;
479
480    fn match_count(&self) -> usize {
481        self.filtered_entries.len()
482    }
483
484    fn selected_index(&self) -> usize {
485        self.selected_index
486    }
487
488    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
489        self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
490        cx.notify();
491    }
492
493    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
494        match self.filtered_entries.get(ix) {
495            Some(ConfigOptionPickerEntry::Option(_)) => true,
496            Some(ConfigOptionPickerEntry::Separator(_)) | None => false,
497        }
498    }
499
500    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
501        "Select an option…".into()
502    }
503
504    fn update_matches(
505        &mut self,
506        query: String,
507        window: &mut Window,
508        cx: &mut Context<Picker<Self>>,
509    ) -> Task<()> {
510        let all_options = self.all_options.clone();
511
512        cx.spawn_in(window, async move |this, cx| {
513            let filtered_options = match this
514                .read_with(cx, |_, cx| {
515                    if query.is_empty() {
516                        None
517                    } else {
518                        Some((all_options.clone(), query.clone(), cx.background_executor().clone()))
519                    }
520                })
521                .ok()
522                .flatten()
523            {
524                Some((options, q, executor)) => fuzzy_search_options(options, &q, executor).await,
525                None => all_options,
526            };
527
528            this.update_in(cx, |this, window, cx| {
529                this.delegate.filtered_entries =
530                    options_to_picker_entries(&filtered_options, &this.delegate.favorites);
531
532                let current_value = this.delegate.current_value();
533                let new_index = current_value
534                    .and_then(|current| {
535                        this.delegate.filtered_entries.iter().position(|entry| {
536                            matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
537                        })
538                    })
539                    .unwrap_or(0);
540
541                this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
542                cx.notify();
543            })
544            .ok();
545        })
546    }
547
548    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
549        if let Some(ConfigOptionPickerEntry::Option(option)) =
550            self.filtered_entries.get(self.selected_index)
551        {
552            if window.modifiers().secondary() {
553                let default_value = self
554                    .agent_server
555                    .default_config_option(self.config_id.0.as_ref(), cx);
556                let is_default = default_value.as_deref() == Some(&*option.value.0);
557
558                self.agent_server.set_default_config_option(
559                    self.config_id.0.as_ref(),
560                    if is_default {
561                        None
562                    } else {
563                        Some(option.value.0.as_ref())
564                    },
565                    self.fs.clone(),
566                    cx,
567                );
568            }
569
570            let task = self.config_options.set_config_option(
571                self.config_id.clone(),
572                option.value.clone(),
573                cx,
574            );
575
576            cx.spawn(async move |_, _| {
577                if let Err(err) = task.await {
578                    log::error!("Failed to set config option: {:?}", err);
579                }
580            })
581            .detach();
582
583            cx.emit(DismissEvent);
584        }
585    }
586
587    fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
588        cx.defer_in(window, |picker, window, cx| {
589            picker.set_query("", window, cx);
590        });
591    }
592
593    fn render_match(
594        &self,
595        ix: usize,
596        selected: bool,
597        _: &mut Window,
598        cx: &mut Context<Picker<Self>>,
599    ) -> Option<Self::ListItem> {
600        match self.filtered_entries.get(ix)? {
601            ConfigOptionPickerEntry::Separator(title) => Some(
602                div()
603                    .when(ix > 0, |this| this.mt_1())
604                    .child(
605                        div()
606                            .px_2()
607                            .py_1()
608                            .text_xs()
609                            .text_color(cx.theme().colors().text_muted)
610                            .child(title.clone()),
611                    )
612                    .into_any_element(),
613            ),
614            ConfigOptionPickerEntry::Option(option) => {
615                let current_value = self.current_value();
616                let is_selected = current_value.as_ref() == Some(&option.value);
617
618                let default_value = self
619                    .agent_server
620                    .default_config_option(self.config_id.0.as_ref(), cx);
621                let is_default = default_value.as_deref() == Some(&*option.value.0);
622
623                let is_favorite = self.favorites.contains(&option.value);
624
625                let option_name = option.name.clone();
626                let description = option.description.clone();
627
628                Some(
629                    div()
630                        .id(("config-option-picker-item", ix))
631                        .when_some(description, |this, desc| {
632                            let desc: SharedString = desc.into();
633                            this.on_hover(cx.listener(move |menu, hovered, _, cx| {
634                                if *hovered {
635                                    menu.delegate.selected_description =
636                                        Some((ix, desc.clone(), is_default));
637                                } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix)
638                                {
639                                    menu.delegate.selected_description = None;
640                                }
641                                cx.notify();
642                            }))
643                        })
644                        .child(
645                            ListItem::new(ix)
646                                .inset(true)
647                                .spacing(ListItemSpacing::Sparse)
648                                .toggle_state(selected)
649                                .child(h_flex().w_full().child(Label::new(option_name).truncate()))
650                                .end_slot(div().pr_2().when(is_selected, |this| {
651                                    this.child(Icon::new(IconName::Check).color(Color::Accent))
652                                }))
653                                .end_hover_slot(div().pr_1p5().child({
654                                    let (icon, color, tooltip) = if is_favorite {
655                                        (IconName::StarFilled, Color::Accent, "Unfavorite")
656                                    } else {
657                                        (IconName::Star, Color::Default, "Favorite")
658                                    };
659
660                                    let config_id = self.config_id.clone();
661                                    let value_id = option.value.clone();
662                                    let agent_server = self.agent_server.clone();
663                                    let fs = self.fs.clone();
664
665                                    IconButton::new(("toggle-favorite-config-option", ix), icon)
666                                        .layer(ElevationIndex::ElevatedSurface)
667                                        .icon_color(color)
668                                        .icon_size(IconSize::Small)
669                                        .tooltip(Tooltip::text(tooltip))
670                                        .on_click(move |_, _, cx| {
671                                            agent_server.toggle_favorite_config_option_value(
672                                                config_id.clone(),
673                                                value_id.clone(),
674                                                !is_favorite,
675                                                fs.clone(),
676                                                cx,
677                                            );
678                                        })
679                                })),
680                        )
681                        .into_any_element(),
682                )
683            }
684        }
685    }
686
687    fn documentation_aside(
688        &self,
689        _window: &mut Window,
690        cx: &mut Context<Picker<Self>>,
691    ) -> Option<ui::DocumentationAside> {
692        self.selected_description
693            .as_ref()
694            .map(|(_, description, is_default)| {
695                let description = description.clone();
696                let is_default = *is_default;
697
698                let settings = AgentSettings::get_global(cx);
699                let side = match settings.dock {
700                    settings::DockPosition::Left => DocumentationSide::Right,
701                    settings::DockPosition::Bottom | settings::DockPosition::Right => {
702                        DocumentationSide::Left
703                    }
704                };
705
706                ui::DocumentationAside::new(
707                    side,
708                    Rc::new(move |_| {
709                        v_flex()
710                            .gap_1()
711                            .child(Label::new(description.clone()))
712                            .child(HoldForDefault::new(is_default))
713                            .into_any_element()
714                    }),
715                )
716            })
717    }
718
719    fn documentation_aside_index(&self) -> Option<usize> {
720        self.selected_description.as_ref().map(|(ix, _, _)| *ix)
721    }
722}
723
724fn extract_options(
725    config_options: &Rc<dyn AgentSessionConfigOptions>,
726    config_id: &acp::SessionConfigId,
727) -> Vec<ConfigOptionValue> {
728    let Some(option) = config_options
729        .config_options()
730        .into_iter()
731        .find(|opt| &opt.id == config_id)
732    else {
733        return Vec::new();
734    };
735
736    match &option.kind {
737        acp::SessionConfigKind::Select(select) => match &select.options {
738            acp::SessionConfigSelectOptions::Ungrouped(options) => options
739                .iter()
740                .map(|opt| ConfigOptionValue {
741                    value: opt.value.clone(),
742                    name: opt.name.clone(),
743                    description: opt.description.clone(),
744                    group: None,
745                })
746                .collect(),
747            acp::SessionConfigSelectOptions::Grouped(groups) => groups
748                .iter()
749                .flat_map(|group| {
750                    group.options.iter().map(|opt| ConfigOptionValue {
751                        value: opt.value.clone(),
752                        name: opt.name.clone(),
753                        description: opt.description.clone(),
754                        group: Some(group.name.clone()),
755                    })
756                })
757                .collect(),
758            _ => Vec::new(),
759        },
760        _ => Vec::new(),
761    }
762}
763
764fn get_current_value(
765    config_options: &Rc<dyn AgentSessionConfigOptions>,
766    config_id: &acp::SessionConfigId,
767) -> Option<acp::SessionConfigValueId> {
768    config_options
769        .config_options()
770        .into_iter()
771        .find(|opt| &opt.id == config_id)
772        .and_then(|opt| match &opt.kind {
773            acp::SessionConfigKind::Select(select) => Some(select.current_value.clone()),
774            _ => None,
775        })
776}
777
778fn options_to_picker_entries(
779    options: &[ConfigOptionValue],
780    favorites: &HashSet<acp::SessionConfigValueId>,
781) -> Vec<ConfigOptionPickerEntry> {
782    let mut entries = Vec::new();
783
784    let mut favorite_options = Vec::new();
785
786    for option in options {
787        if favorites.contains(&option.value) {
788            favorite_options.push(option.clone());
789        }
790    }
791
792    if !favorite_options.is_empty() {
793        entries.push(ConfigOptionPickerEntry::Separator("Favorites".into()));
794        for option in favorite_options {
795            entries.push(ConfigOptionPickerEntry::Option(option));
796        }
797
798        // If the remaining list would start ungrouped (group == None), insert a separator so
799        // Favorites doesn't visually run into the main list.
800        if let Some(option) = options.first()
801            && option.group.is_none()
802        {
803            entries.push(ConfigOptionPickerEntry::Separator("All Options".into()));
804        }
805    }
806
807    let mut current_group: Option<String> = None;
808    for option in options {
809        if option.group != current_group {
810            if let Some(group_name) = &option.group {
811                entries.push(ConfigOptionPickerEntry::Separator(
812                    group_name.clone().into(),
813                ));
814            }
815            current_group = option.group.clone();
816        }
817        entries.push(ConfigOptionPickerEntry::Option(option.clone()));
818    }
819
820    entries
821}
822
823async fn fuzzy_search_options(
824    options: Vec<ConfigOptionValue>,
825    query: &str,
826    executor: BackgroundExecutor,
827) -> Vec<ConfigOptionValue> {
828    let candidates = options
829        .iter()
830        .enumerate()
831        .map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name))
832        .collect::<Vec<_>>();
833
834    let mut matches = fuzzy::match_strings(
835        &candidates,
836        query,
837        false,
838        true,
839        100,
840        &Default::default(),
841        executor,
842    )
843    .await;
844
845    matches.sort_unstable_by_key(|mat| {
846        let candidate = &candidates[mat.candidate_id];
847        (Reverse(OrderedFloat(mat.score)), candidate.id)
848    });
849
850    matches
851        .into_iter()
852        .map(|mat| options[mat.candidate_id].clone())
853        .collect()
854}
855
856fn find_option_name(
857    options: &acp::SessionConfigSelectOptions,
858    value_id: &acp::SessionConfigValueId,
859) -> Option<String> {
860    match options {
861        acp::SessionConfigSelectOptions::Ungrouped(opts) => opts
862            .iter()
863            .find(|o| &o.value == value_id)
864            .map(|o| o.name.clone()),
865        acp::SessionConfigSelectOptions::Grouped(groups) => groups.iter().find_map(|group| {
866            group
867                .options
868                .iter()
869                .find(|o| &o.value == value_id)
870                .map(|o| o.name.clone())
871        }),
872        _ => None,
873    }
874}
875
876fn count_config_options(option: &acp::SessionConfigOption) -> usize {
877    match &option.kind {
878        acp::SessionConfigKind::Select(select) => match &select.options {
879            acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(),
880            acp::SessionConfigSelectOptions::Grouped(groups) => {
881                groups.iter().map(|g| g.options.len()).sum()
882            }
883            _ => 0,
884        },
885        _ => 0,
886    }
887}