use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::AgentSessionConfigOptions; use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use collections::HashSet; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ App, BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*, }; use ordered_float::OrderedFloat; use picker::popover_menu::PickerPopoverMenu; use picker::{Picker, PickerDelegate}; use settings::{AgentConfigOptionValue, SettingsStore}; use ui::{ ElevationIndex, IconButton, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Switch, SwitchLabelPosition, ToggleState, Tooltip, prelude::*, }; use unicode_segmentation::UnicodeSegmentation; use util::ResultExt as _; use zed_actions::agent::ToggleModelSelector; use crate::ui::documentation_aside_side; use crate::{ CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, ToggleProfileSelector, ToggleThinkingEffortMenu, }; const PICKER_THRESHOLD: usize = 5; pub struct ConfigOptionsView { config_options: Rc, selectors: Vec>, agent_server: Rc, fs: Arc, config_option_ids: Vec, _refresh_task: Task<()>, } impl ConfigOptionsView { pub fn new( config_options: Rc, agent_server: Rc, fs: Arc, window: &mut Window, cx: &mut Context, ) -> Self { let selectors = Self::build_selectors(&config_options, &agent_server, &fs, window, cx); let config_option_ids = Self::config_option_ids(&config_options); let rx = config_options.watch(cx); let refresh_task = cx.spawn_in(window, async move |this, cx| { if let Some(mut rx) = rx { while let Ok(()) = rx.recv().await { this.update_in(cx, |this, window, cx| { this.rebuild_selectors(window, cx); cx.notify(); }) .log_err(); } } }); Self { config_options, selectors, agent_server, fs, config_option_ids, _refresh_task: refresh_task, } } pub fn toggle_category_picker( &mut self, category: acp::SessionConfigOptionCategory, window: &mut Window, cx: &mut Context, ) -> bool { let Some(config_id) = self.first_config_option_id_matching(category, |option| { matches!(&option.kind, acp::SessionConfigKind::Select(_)) }) else { return false; }; let Some(selector) = self.selector_for_config_id(&config_id, cx) else { return false; }; selector.update(cx, |selector, cx| selector.toggle_picker(window, cx)) } pub fn cycle_category_option( &mut self, category: acp::SessionConfigOptionCategory, favorites_only: bool, cx: &mut Context, ) -> bool { let Some(config_id) = self.first_config_option_id_matching(category, |option| { Self::can_cycle_config_option(option, favorites_only) }) else { return false; }; let Some(next_value) = self.next_value_for_config(&config_id, favorites_only, cx) else { return false; }; let default_value = setting_value_for_config_option_value(&next_value); self.agent_server.set_default_config_option( config_id.0.as_ref(), default_value, self.fs.clone(), cx, ); let task = self .config_options .set_config_option(config_id, next_value, cx); cx.spawn(async move |_, _| { if let Err(err) = task.await { log::error!("Failed to set config option: {:?}", err); } }) .detach(); true } fn first_config_option_id_matching( &self, category: acp::SessionConfigOptionCategory, predicate: impl Fn(&acp::SessionConfigOption) -> bool, ) -> Option { self.config_options .config_options() .into_iter() .find(|option| option.category.as_ref() == Some(&category) && predicate(option)) .map(|option| option.id) } fn can_cycle_config_option(option: &acp::SessionConfigOption, favorites_only: bool) -> bool { match &option.kind { acp::SessionConfigKind::Select(_) => true, acp::SessionConfigKind::Boolean(_) => !favorites_only, _ => false, } } fn selector_for_config_id( &self, config_id: &acp::SessionConfigId, cx: &App, ) -> Option> { self.selectors .iter() .find(|selector| selector.read(cx).config_id() == config_id) .cloned() } fn next_value_for_config( &self, config_id: &acp::SessionConfigId, favorites_only: bool, cx: &mut Context, ) -> Option { let option = self .config_options .config_options() .into_iter() .find(|option| &option.id == config_id)?; match &option.kind { acp::SessionConfigKind::Select(_) => { let mut options = extract_options(&self.config_options, config_id); if options.is_empty() { return None; } if favorites_only { let favorites = self .agent_server .favorite_config_option_value_ids(config_id, cx); options.retain(|option| favorites.contains(&option.value)); if options.is_empty() { return None; } } let current_value = get_current_select_value(&self.config_options, config_id); let current_index = current_value .as_ref() .and_then(|current| options.iter().position(|option| &option.value == current)) .unwrap_or(usize::MAX); let next_index = if current_index == usize::MAX { 0 } else { (current_index + 1) % options.len() }; Some(acp::SessionConfigOptionValue::value_id( options[next_index].value.clone(), )) } acp::SessionConfigKind::Boolean(boolean) => { if favorites_only { None } else { Some(acp::SessionConfigOptionValue::boolean( !boolean.current_value, )) } } _ => None, } } fn config_option_ids( config_options: &Rc, ) -> Vec { config_options .config_options() .into_iter() .map(|option| option.id) .collect() } fn rebuild_selectors(&mut self, window: &mut Window, cx: &mut Context) { // Config option updates can mutate option values for existing IDs (for example, // reasoning levels after a model switch). Rebuild to refresh cached picker entries. self.config_option_ids = Self::config_option_ids(&self.config_options); self.selectors = Self::build_selectors( &self.config_options, &self.agent_server, &self.fs, window, cx, ); cx.notify(); } fn build_selectors( config_options: &Rc, agent_server: &Rc, fs: &Arc, window: &mut Window, cx: &mut Context, ) -> Vec> { config_options .config_options() .into_iter() .map(|option| { let config_options = config_options.clone(); let agent_server = agent_server.clone(); let fs = fs.clone(); cx.new(|cx| { ConfigOptionSelector::new( config_options, option.id.clone(), agent_server, fs, window, cx, ) }) }) .collect() } } impl Render for ConfigOptionsView { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { if self.selectors.is_empty() { return div().into_any_element(); } h_flex() .min_w_0() .flex_wrap() .gap_1() .children(self.selectors.iter().cloned()) .into_any_element() } } struct ConfigOptionSelector { config_options: Rc, config_id: acp::SessionConfigId, agent_server: Rc, fs: Arc, picker_handle: Option>>, picker: Option>>, setting_value: bool, } impl ConfigOptionSelector { pub fn new( config_options: Rc, config_id: acp::SessionConfigId, agent_server: Rc, fs: Arc, window: &mut Window, cx: &mut Context, ) -> Self { let current_option = config_options .config_options() .into_iter() .find(|opt| opt.id == config_id); let option_count = current_option .as_ref() .map(count_config_options) .unwrap_or(0); let is_select = current_option .as_ref() .is_some_and(|option| matches!(&option.kind, acp::SessionConfigKind::Select(_))); let is_searchable = option_count >= PICKER_THRESHOLD; let (picker_handle, picker) = if is_select { let config_options = config_options.clone(); let config_id = config_id.clone(); let agent_server = agent_server.clone(); let fs = fs.clone(); let picker = cx.new(move |picker_cx| { let delegate = ConfigOptionPickerDelegate::new( config_options, config_id, agent_server, fs, window, picker_cx, ); if is_searchable { Picker::list(delegate, window, picker_cx) } else { Picker::nonsearchable_list(delegate, window, picker_cx) } .show_scrollbar(true) .initial_width(rems(20.)) }); (Some(PopoverMenuHandle::default()), Some(picker)) } else { (None, None) }; Self { config_options, config_id, agent_server, fs, picker_handle, picker, setting_value: false, } } fn current_option(&self) -> Option { self.config_options .config_options() .into_iter() .find(|opt| opt.id == self.config_id) } fn config_id(&self) -> &acp::SessionConfigId { &self.config_id } fn toggle_picker(&self, window: &mut Window, cx: &mut Context) -> bool { if let Some(picker_handle) = &self.picker_handle { picker_handle.toggle(window, cx); true } else { false } } fn current_value_name(&self) -> String { let Some(option) = self.current_option() else { return "Unknown".to_string(); }; match &option.kind { acp::SessionConfigKind::Select(select) => { find_option_name(&select.options, &select.current_value) .unwrap_or_else(|| "Unknown".to_string()) } _ => "Unknown".to_string(), } } fn handles_category_keybindings(&self, category: &acp::SessionConfigOptionCategory) -> bool { self.config_options .config_options() .into_iter() .find(|option| { option.category.as_ref() == Some(category) && matches!(&option.kind, acp::SessionConfigKind::Select(_)) }) .is_some_and(|option| option.id == self.config_id) } fn render_trigger_button(&self, _window: &mut Window, _cx: &mut Context) -> Button { let Some(option) = self.current_option() else { return Button::new("config-option-trigger", "Unknown") .label_size(LabelSize::Small) .color(Color::Muted) .disabled(true); }; let picker_deployed = self .picker_handle .as_ref() .is_some_and(|picker_handle| picker_handle.is_deployed()); let icon = if picker_deployed { IconName::ChevronUp } else { IconName::ChevronDown }; let value_name = self.current_value_name(); let mut graphemes = value_name.graphemes(true); let truncated = graphemes.by_ref().take(32).collect::(); let display_name = if graphemes.next().is_some() { format!("{truncated}…") } else { truncated }; Button::new( ElementId::Name(format!("config-option-{}", option.id.0).into()), display_name, ) .label_size(LabelSize::Small) .color(Color::Muted) .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) .disabled(self.setting_value) } } impl Render for ConfigOptionSelector { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(option) = self.current_option() else { return div().into_any_element(); }; match &option.kind { acp::SessionConfigKind::Select(_) => { let (Some(picker), Some(picker_handle)) = (self.picker.clone(), self.picker_handle.clone()) else { return div().into_any_element(); }; let trigger_button = self.render_trigger_button(window, cx); let show_category_keybindings = option .category .as_ref() .is_some_and(|category| self.handles_category_keybindings(category)); let option_category = option.category.clone(); let option_name = option.name.clone(); let option_description: Option = option.description.clone().map(Into::into); let tooltip = Tooltip::element(move |_window, cx| { let mut content = v_flex().gap_1().child(Label::new(option_name.clone())); if let Some(desc) = option_description.as_ref() { content = content.child( Label::new(desc.clone()) .size(LabelSize::Small) .color(Color::Muted), ); } let action_tooltip_container = |label: &str, keybinding: KeyBinding| { h_flex() .pt_1() .gap_2() .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(Label::new(label)) .child(keybinding) }; if show_category_keybindings && let Some(category) = &option_category { match category { acp::SessionConfigOptionCategory::Mode => { content = content .child(action_tooltip_container( "Change Mode", KeyBinding::for_action(&ToggleProfileSelector, cx), )) .child(action_tooltip_container( "Cycle Through Modes", KeyBinding::for_action(&CycleModeSelector, cx), )); } acp::SessionConfigOptionCategory::Model => { content = content .child(action_tooltip_container( "Change Model", KeyBinding::for_action(&ToggleModelSelector, cx), )) .child(action_tooltip_container( "Cycle Favorite Models", KeyBinding::for_action(&CycleFavoriteModels, cx), )); } acp::SessionConfigOptionCategory::ThoughtLevel => { content = content .child(action_tooltip_container( "Change Thinking Effort", KeyBinding::for_action(&ToggleThinkingEffortMenu, cx), )) .child(action_tooltip_container( "Cycle Thinking Effort", KeyBinding::for_action(&CycleThinkingEffort, cx), )); } _ => {} } } content.into_any() }); PickerPopoverMenu::new( picker, trigger_button, tooltip, gpui::Anchor::BottomRight, cx, ) .with_handle(picker_handle) .render(window, cx) .into_any_element() } acp::SessionConfigKind::Boolean(boolean) => { let option_id = option.id.clone(); let option_name: SharedString = option.name.clone().into(); let option_description: Option = option.description.clone().map(Into::into); let tooltip_name = option_name.clone(); let tooltip = Tooltip::element(move |_window, _cx| { let mut content = v_flex().gap_1().child(Label::new(tooltip_name.clone())); if let Some(desc) = option_description.as_ref() { content = content.child( Label::new(desc.clone()) .size(LabelSize::Small) .color(Color::Muted), ); } content.into_any() }); let config_id = self.config_id.clone(); let config_options = self.config_options.clone(); let agent_server = self.agent_server.clone(); let fs = self.fs.clone(); let current_value = boolean.current_value; let toggle_state = if current_value { ToggleState::Selected } else { ToggleState::Unselected }; h_flex() .id(ElementId::Name( format!("config-option-{}", option_id.0).into(), )) .pr_1() .tooltip(tooltip) .child( Switch::new( ElementId::Name(format!("config-option-{}-switch", option_id.0).into()), toggle_state, ) .label(option_name) .label_position(SwitchLabelPosition::Start) .label_size(LabelSize::Small) .label_color(Color::Muted) .disabled(self.setting_value) .on_click(move |state, _window, cx| { let next_value = matches!(state, ToggleState::Selected); agent_server.set_default_config_option( config_id.0.as_ref(), Some(AgentConfigOptionValue::Boolean(next_value)), fs.clone(), cx, ); let task = config_options.set_config_option( config_id.clone(), acp::SessionConfigOptionValue::boolean(next_value), cx, ); cx.spawn(async move |_| { if let Err(err) = task.await { log::error!("Failed to set config option: {:?}", err); } }) .detach(); }), ) .into_any_element() } _ => div().into_any_element(), } } } #[derive(Clone)] enum ConfigOptionPickerEntry { Separator(SharedString), Option(ConfigOptionValue), } #[derive(Clone)] struct ConfigOptionValue { value: acp::SessionConfigValueId, name: String, description: Option, group: Option, } struct ConfigOptionPickerDelegate { config_options: Rc, config_id: acp::SessionConfigId, agent_server: Rc, fs: Arc, filtered_entries: Vec, all_options: Vec, selected_index: usize, selected_description: Option<(usize, SharedString)>, favorites: HashSet, _settings_subscription: Subscription, } impl ConfigOptionPickerDelegate { fn new( config_options: Rc, config_id: acp::SessionConfigId, agent_server: Rc, fs: Arc, window: &mut Window, cx: &mut Context>, ) -> Self { let favorites = agent_server.favorite_config_option_value_ids(&config_id, cx); let all_options = extract_options(&config_options, &config_id); let filtered_entries = options_to_picker_entries(&all_options, &favorites); let current_value = get_current_select_value(&config_options, &config_id); let selected_index = current_value .and_then(|current| { filtered_entries.iter().position(|entry| { matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current) }) }) .unwrap_or(0); let agent_server_for_subscription = agent_server.clone(); let config_id_for_subscription = config_id.clone(); let settings_subscription = cx.observe_global_in::(window, move |picker, window, cx| { let new_favorites = agent_server_for_subscription .favorite_config_option_value_ids(&config_id_for_subscription, cx); if new_favorites != picker.delegate.favorites { picker.delegate.favorites = new_favorites; picker.refresh(window, cx); } }); cx.notify(); Self { config_options, config_id, agent_server, fs, filtered_entries, all_options, selected_index, selected_description: None, favorites, _settings_subscription: settings_subscription, } } fn current_value(&self) -> Option { get_current_select_value(&self.config_options, &self.config_id) } } impl PickerDelegate for ConfigOptionPickerDelegate { type ListItem = AnyElement; fn name() -> &'static str { "config options" } fn match_count(&self) -> usize { self.filtered_entries.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); cx.notify(); } fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { match self.filtered_entries.get(ix) { Some(ConfigOptionPickerEntry::Option(_)) => true, Some(ConfigOptionPickerEntry::Separator(_)) | None => false, } } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select an option…".into() } fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context>, ) -> Task<()> { let all_options = self.all_options.clone(); cx.spawn_in(window, async move |this, cx| { let filtered_options = match this .read_with(cx, |_, cx| { if query.is_empty() { None } else { Some((all_options.clone(), query.clone(), cx.background_executor().clone())) } }) .ok() .flatten() { Some((options, q, executor)) => fuzzy_search_options(options, &q, executor).await, None => all_options, }; this.update_in(cx, |this, window, cx| { this.delegate.filtered_entries = options_to_picker_entries(&filtered_options, &this.delegate.favorites); let current_value = this.delegate.current_value(); let new_index = current_value .and_then(|current| { this.delegate.filtered_entries.iter().position(|entry| { matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current) }) }) .unwrap_or(0); this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx); cx.notify(); }) .ok(); }) } fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { if let Some(ConfigOptionPickerEntry::Option(option)) = self.filtered_entries.get(self.selected_index) { self.agent_server.set_default_config_option( self.config_id.0.as_ref(), Some(AgentConfigOptionValue::ValueId(option.value.0.to_string())), self.fs.clone(), cx, ); let task = self.config_options.set_config_option( self.config_id.clone(), acp::SessionConfigOptionValue::value_id(option.value.clone()), cx, ); cx.spawn(async move |_, _| { if let Err(err) = task.await { log::error!("Failed to set config option: {:?}", err); } }) .detach(); cx.emit(DismissEvent); } } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { cx.defer_in(window, |picker, window, cx| { picker.set_query("", window, cx); }); } fn render_match( &self, ix: usize, selected: bool, _: &mut Window, cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { ConfigOptionPickerEntry::Separator(title) => Some( div() .when(ix > 0, |this| this.mt_1()) .child( div() .px_2() .py_1() .text_xs() .text_color(cx.theme().colors().text_muted) .child(title.clone()), ) .into_any_element(), ), ConfigOptionPickerEntry::Option(option) => { let current_value = self.current_value(); let is_selected = current_value.as_ref() == Some(&option.value); let is_favorite = self.favorites.contains(&option.value); let option_name = option.name.clone(); let description = option.description.clone(); Some( div() .id(("config-option-picker-item", ix)) .when_some(description, |this, desc| { let desc: SharedString = desc.into(); this.on_hover(cx.listener(move |menu, hovered, _, cx| { if *hovered { menu.delegate.selected_description = Some((ix, desc.clone())); } else if matches!(menu.delegate.selected_description, Some((id, _)) if id == ix) { menu.delegate.selected_description = None; } cx.notify(); })) }) .child( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .child(h_flex().w_full().child(Label::new(option_name).truncate())) .end_slot(div().pr_2().when(is_selected, |this| { this.child(Icon::new(IconName::Check).color(Color::Accent)) })) .end_slot_on_hover(div().pr_1p5().child({ let (icon, color, tooltip) = if is_favorite { (IconName::StarFilled, Color::Accent, "Unfavorite") } else { (IconName::Star, Color::Default, "Favorite") }; let config_id = self.config_id.clone(); let value_id = option.value.clone(); let agent_server = self.agent_server.clone(); let fs = self.fs.clone(); IconButton::new(("toggle-favorite-config-option", ix), icon) .layer(ElevationIndex::ElevatedSurface) .icon_color(color) .icon_size(IconSize::Small) .tooltip(Tooltip::text(tooltip)) .on_click(move |_, _, cx| { agent_server.toggle_favorite_config_option_value( config_id.clone(), value_id.clone(), !is_favorite, fs.clone(), cx, ); }) })), ) .into_any_element(), ) } } } fn documentation_aside( &self, _window: &mut Window, cx: &mut Context>, ) -> Option { self.selected_description.as_ref().map(|(_, description)| { let description = description.clone(); let side = documentation_aside_side(cx); ui::DocumentationAside::new( side, Rc::new(move |_| Label::new(description.clone()).into_any_element()), ) }) } fn documentation_aside_index(&self) -> Option { self.selected_description.as_ref().map(|(ix, _)| *ix) } } fn extract_options( config_options: &Rc, config_id: &acp::SessionConfigId, ) -> Vec { let Some(option) = config_options .config_options() .into_iter() .find(|opt| &opt.id == config_id) else { return Vec::new(); }; match &option.kind { acp::SessionConfigKind::Select(select) => match &select.options { acp::SessionConfigSelectOptions::Ungrouped(options) => options .iter() .map(|opt| ConfigOptionValue { value: opt.value.clone(), name: opt.name.clone(), description: opt.description.clone(), group: None, }) .collect(), acp::SessionConfigSelectOptions::Grouped(groups) => groups .iter() .flat_map(|group| { group.options.iter().map(|opt| ConfigOptionValue { value: opt.value.clone(), name: opt.name.clone(), description: opt.description.clone(), group: Some(group.name.clone()), }) }) .collect(), _ => Vec::new(), }, _ => Vec::new(), } } fn get_current_select_value( config_options: &Rc, config_id: &acp::SessionConfigId, ) -> Option { config_options .config_options() .into_iter() .find(|opt| &opt.id == config_id) .and_then(|opt| match &opt.kind { acp::SessionConfigKind::Select(select) => Some(select.current_value.clone()), _ => None, }) } fn setting_value_for_config_option_value( value: &acp::SessionConfigOptionValue, ) -> Option { match value { acp::SessionConfigOptionValue::ValueId { value } => { Some(AgentConfigOptionValue::ValueId(value.0.to_string())) } acp::SessionConfigOptionValue::Boolean { value } => { Some(AgentConfigOptionValue::Boolean(*value)) } _ => None, } } fn options_to_picker_entries( options: &[ConfigOptionValue], favorites: &HashSet, ) -> Vec { let mut entries = Vec::new(); let mut favorite_options = Vec::new(); for option in options { if favorites.contains(&option.value) { favorite_options.push(option.clone()); } } if !favorite_options.is_empty() { entries.push(ConfigOptionPickerEntry::Separator("Favorites".into())); for option in favorite_options { entries.push(ConfigOptionPickerEntry::Option(option)); } // If the remaining list would start ungrouped (group == None), insert a separator so // Favorites doesn't visually run into the main list. if let Some(option) = options.first() && option.group.is_none() { entries.push(ConfigOptionPickerEntry::Separator("All Options".into())); } } let mut current_group: Option = None; for option in options { if option.group != current_group { if let Some(group_name) = &option.group { entries.push(ConfigOptionPickerEntry::Separator( group_name.clone().into(), )); } current_group = option.group.clone(); } entries.push(ConfigOptionPickerEntry::Option(option.clone())); } entries } async fn fuzzy_search_options( options: Vec, query: &str, executor: BackgroundExecutor, ) -> Vec { let candidates = options .iter() .enumerate() .map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name)) .collect::>(); let mut matches = fuzzy::match_strings( &candidates, query, false, true, 100, &Default::default(), executor, ) .await; matches.sort_unstable_by_key(|mat| { let candidate = &candidates[mat.candidate_id]; (Reverse(OrderedFloat(mat.score)), candidate.id) }); matches .into_iter() .map(|mat| options[mat.candidate_id].clone()) .collect() } fn find_option_name( options: &acp::SessionConfigSelectOptions, value_id: &acp::SessionConfigValueId, ) -> Option { match options { acp::SessionConfigSelectOptions::Ungrouped(opts) => opts .iter() .find(|o| &o.value == value_id) .map(|o| o.name.clone()), acp::SessionConfigSelectOptions::Grouped(groups) => groups.iter().find_map(|group| { group .options .iter() .find(|o| &o.value == value_id) .map(|o| o.name.clone()) }), _ => None, } } fn count_config_options(option: &acp::SessionConfigOption) -> usize { match &option.kind { acp::SessionConfigKind::Select(select) => match &select.options { acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(), acp::SessionConfigSelectOptions::Grouped(groups) => { groups.iter().map(|g| g.options.len()).sum() } _ => 0, }, _ => 0, } } #[cfg(test)] mod tests { use super::*; use acp_thread::AgentConnection; use fs::FakeFs; use gpui::TestAppContext; use parking_lot::Mutex; use project::{AgentId, Project}; use std::{any::Any, cell::RefCell}; #[gpui::test] fn cycling_config_option_saves_selected_value_as_default(cx: &mut TestAppContext) { let agent_server = Rc::new(TestAgentServer::default()); let config_options = Rc::new(TestSessionConfigOptions::new(vec![ acp::SessionConfigOption::select( "mode", "Mode", "auto", vec![ acp::SessionConfigSelectOption::new("auto", "Auto"), acp::SessionConfigSelectOption::new("manual", "Manual"), ], ) .category(acp::SessionConfigOptionCategory::Mode), ])); let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); let view = cx.new(|_| ConfigOptionsView { config_option_ids: ConfigOptionsView::config_option_ids(&config_options), config_options, selectors: Vec::new(), agent_server, fs, _refresh_task: Task::ready(()), }); assert!(view.update(cx, |view, cx| { view.cycle_category_option(acp::SessionConfigOptionCategory::Mode, false, cx) })); }); assert_eq!( agent_server.saved_defaults.lock().as_slice(), &[( "mode".to_string(), Some(AgentConfigOptionValue::ValueId("manual".to_string())) )] ); assert_eq!( config_options.set_values.borrow().as_slice(), &[( "mode".to_string(), acp::SessionConfigOptionValue::value_id("manual") )] ); } #[gpui::test] fn cycling_boolean_config_option_saves_selected_value_as_default(cx: &mut TestAppContext) { let agent_server = Rc::new(TestAgentServer::default()); let config_options = Rc::new(TestSessionConfigOptions::new(vec![ acp::SessionConfigOption::boolean("web_search", "Web Search", false) .category(acp::SessionConfigOptionCategory::ModelConfig), ])); let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); let view = cx.new(|_| ConfigOptionsView { config_option_ids: ConfigOptionsView::config_option_ids(&config_options), config_options, selectors: Vec::new(), agent_server, fs, _refresh_task: Task::ready(()), }); assert!(view.update(cx, |view, cx| { view.cycle_category_option(acp::SessionConfigOptionCategory::ModelConfig, false, cx) })); }); assert_eq!( agent_server.saved_defaults.lock().as_slice(), &[( "web_search".to_string(), Some(AgentConfigOptionValue::Boolean(true)) )] ); assert_eq!( config_options.set_values.borrow().as_slice(), &[( "web_search".to_string(), acp::SessionConfigOptionValue::boolean(true) )] ); } #[gpui::test] fn cycling_category_cycles_boolean_config_option_first(cx: &mut TestAppContext) { let agent_server = Rc::new(TestAgentServer::default()); let config_options = Rc::new(TestSessionConfigOptions::new(vec![ acp::SessionConfigOption::boolean("web_search", "Web Search", false) .category(acp::SessionConfigOptionCategory::Model), acp::SessionConfigOption::select( "model", "Model", "small", vec![ acp::SessionConfigSelectOption::new("small", "Small"), acp::SessionConfigSelectOption::new("large", "Large"), ], ) .category(acp::SessionConfigOptionCategory::Model), ])); let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); let view = cx.new(|_| ConfigOptionsView { config_option_ids: ConfigOptionsView::config_option_ids(&config_options), config_options, selectors: Vec::new(), agent_server, fs, _refresh_task: Task::ready(()), }); assert!(view.update(cx, |view, cx| { view.cycle_category_option(acp::SessionConfigOptionCategory::Model, false, cx) })); }); assert_eq!( agent_server.saved_defaults.lock().as_slice(), &[( "web_search".to_string(), Some(AgentConfigOptionValue::Boolean(true)) )] ); assert_eq!( config_options.set_values.borrow().as_slice(), &[( "web_search".to_string(), acp::SessionConfigOptionValue::boolean(true) )] ); } #[gpui::test] fn toggling_category_picker_without_select_config_option_is_unhandled(cx: &mut TestAppContext) { let agent_server = Rc::new(TestAgentServer::default()); let config_options = Rc::new(TestSessionConfigOptions::new(vec![ acp::SessionConfigOption::boolean("web_search", "Web Search", false) .category(acp::SessionConfigOptionCategory::Model), ])); let fs: Arc = FakeFs::new(cx.executor()); let cx = cx.add_empty_window(); let view = cx.update({ move |window, cx| { let config_options: Rc = config_options; let agent_server: Rc = agent_server; cx.new(|cx| ConfigOptionsView::new(config_options, agent_server, fs, window, cx)) } }); let handled = cx.update(|window, cx| { view.update(cx, |view, cx| { view.toggle_category_picker(acp::SessionConfigOptionCategory::Model, window, cx) }) }); assert!(!handled); } #[derive(Default)] struct TestAgentServer { saved_defaults: Arc)>>>, } impl AgentServer for TestAgentServer { fn logo(&self) -> IconName { IconName::ZedAssistant } fn agent_id(&self) -> AgentId { AgentId::new("test-agent") } fn connect( &self, _delegate: agent_servers::AgentServerDelegate, _project: Entity, _cx: &mut App, ) -> Task>> { Task::ready(Err(anyhow::anyhow!("test agent server cannot connect"))) } fn into_any(self: Rc) -> Rc { self } fn set_default_config_option( &self, config_id: &str, value: Option, _fs: Arc, _cx: &mut App, ) { self.saved_defaults .lock() .push((config_id.to_string(), value)); } } struct TestSessionConfigOptions { options: RefCell>, set_values: RefCell>, } impl TestSessionConfigOptions { fn new(options: Vec) -> Self { Self { options: RefCell::new(options), set_values: RefCell::new(Vec::new()), } } } impl AgentSessionConfigOptions for TestSessionConfigOptions { fn config_options(&self) -> Vec { self.options.borrow().clone() } fn set_config_option( &self, config_id: acp::SessionConfigId, value: acp::SessionConfigOptionValue, _cx: &mut App, ) -> Task>> { self.set_values .borrow_mut() .push((config_id.0.to_string(), value.clone())); let options = { let mut options = self.options.borrow_mut(); if let Some(option) = options.iter_mut().find(|option| option.id == config_id) { match (&mut option.kind, value) { ( acp::SessionConfigKind::Select(select), acp::SessionConfigOptionValue::ValueId { value }, ) => { select.current_value = value; } ( acp::SessionConfigKind::Boolean(boolean), acp::SessionConfigOptionValue::Boolean { value }, ) => { boolean.current_value = value; } _ => {} } } options.clone() }; Task::ready(Ok(options)) } } }