acp: Support boolean ACP config options (#59698)

Still behind a feature flag until the RFD is completed.

Release Notes:

- N/A

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Ben Brandt 2026-06-30 12:12:25 +02:00 committed by GitHub
parent 8186af99a3
commit 45015f89d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 975 additions and 222 deletions

View file

@ -310,7 +310,7 @@ pub trait AgentSessionConfigOptions {
fn set_config_option(
&self,
config_id: acp::SessionConfigId,
value: acp::SessionConfigValueId,
value: acp::SessionConfigOptionValue,
cx: &mut App,
) -> Task<Result<Vec<acp::SessionConfigOption>>>;

View file

@ -22,7 +22,7 @@ use project::agent_server_store::{
use project::{AgentId, Project};
use remote::remote_client::Interactive;
use serde::Deserialize;
use settings::SettingsStore;
use settings::{AgentConfigOptionValue, SettingsStore};
use std::path::PathBuf;
use std::process::{ExitStatus, Stdio};
use std::rc::Rc;
@ -408,11 +408,14 @@ pub struct AcpConnection {
#[derive(Clone, Default)]
struct AcpConnectionDefaults {
mode: Rc<RefCell<Option<acp::SessionModeId>>>,
config_options: Rc<RefCell<HashMap<String, String>>>,
config_options: Rc<RefCell<HashMap<String, AgentConfigOptionValue>>>,
}
impl AcpConnectionDefaults {
fn new(mode: Option<acp::SessionModeId>, config_options: HashMap<String, String>) -> Self {
fn new(
mode: Option<acp::SessionModeId>,
config_options: HashMap<String, AgentConfigOptionValue>,
) -> Self {
Self {
mode: Rc::new(RefCell::new(mode)),
config_options: Rc::new(RefCell::new(config_options)),
@ -423,11 +426,15 @@ impl AcpConnectionDefaults {
self.mode.borrow().clone()
}
fn config_option(&self, config_id: &str) -> Option<String> {
fn config_option(&self, config_id: &str) -> Option<AgentConfigOptionValue> {
self.config_options.borrow().get(config_id).cloned()
}
fn set(&self, mode: Option<acp::SessionModeId>, config_options: HashMap<String, String>) {
fn set(
&self,
mode: Option<acp::SessionModeId>,
config_options: HashMap<String, AgentConfigOptionValue>,
) {
*self.mode.borrow_mut() = mode;
*self.config_options.borrow_mut() = config_options;
}
@ -627,7 +634,7 @@ pub async fn connect(
command: AgentServerCommand,
agent_server_store: WeakEntity<AgentServerStore>,
default_mode: Option<acp::SessionModeId>,
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
cx: &mut AsyncApp,
) -> Result<Rc<dyn AgentConnection>> {
let conn = AcpConnection::stdio(
@ -736,7 +743,10 @@ fn connect_client_future(
)
}
fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities {
fn client_capabilities_for_agent(
agent_id: &AgentId,
supports_boolean_config_options: bool,
) -> acp::ClientCapabilities {
let mut meta = acp::Meta::from_iter([
("terminal_output".into(), true.into()),
("terminal-auth".into(), true.into()),
@ -746,13 +756,24 @@ fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities
meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into());
}
acp::ClientCapabilities::new()
let mut capabilities = acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new()
.read_text_file(true)
.write_text_file(true))
.terminal(true)
.auth(acp::AuthCapabilities::new().terminal(true))
.meta(meta)
.meta(meta);
if supports_boolean_config_options {
capabilities = capabilities.session(
acp::ClientSessionCapabilities::new().config_options(
acp::SessionConfigOptionsCapabilities::new()
.boolean(acp::BooleanConfigOptionCapabilities::new()),
),
);
}
capabilities
}
impl AcpConnection {
@ -771,7 +792,7 @@ impl AcpConnection {
command: AgentServerCommand,
agent_server_store: WeakEntity<AgentServerStore>,
default_mode: Option<acp::SessionModeId>,
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
cx: &mut AsyncApp,
) -> Result<Self> {
let root_dir = project.read_with(cx, |project, cx| {
@ -942,7 +963,10 @@ impl AcpConnection {
let initialize_response = connection
.send_request(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities_for_agent(&agent_id))
.client_capabilities(client_capabilities_for_agent(
&agent_id,
cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()),
))
.client_info(
acp::Implementation::new("zed", version)
.title(release_channel.map(ToOwned::to_owned)),
@ -1255,6 +1279,7 @@ impl AcpConnection {
cx: &mut AsyncApp,
) {
let id = self.id.clone();
let apply_boolean_defaults = cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>());
let defaults_to_apply: Vec<_> = {
let config_opts_ref = config_options.borrow();
config_opts_ref
@ -1262,31 +1287,54 @@ impl AcpConnection {
.filter_map(|config_option| {
let default_value = self.defaults.config_option(config_option.id.0.as_ref())?;
let is_valid = match &config_option.kind {
acp::SessionConfigKind::Select(select) => match &select.options {
acp::SessionConfigSelectOptions::Ungrouped(options) => options
.iter()
.any(|opt| &*opt.value.0 == default_value.as_str()),
acp::SessionConfigSelectOptions::Grouped(groups) => {
groups.iter().any(|g| {
g.options
.iter()
.any(|opt| &*opt.value.0 == default_value.as_str())
})
let value_to_apply = match &config_option.kind {
acp::SessionConfigKind::Select(select) => {
let value_id = default_value.as_value_id()?;
match &select.options {
acp::SessionConfigSelectOptions::Ungrouped(options) => options
.iter()
.any(|opt| &*opt.value.0 == value_id)
.then(|| {
acp::SessionConfigOptionValue::value_id(
value_id.to_string(),
)
}),
acp::SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.any(|group| {
group.options.iter().any(|opt| &*opt.value.0 == value_id)
})
.then(|| {
acp::SessionConfigOptionValue::value_id(
value_id.to_string(),
)
}),
_ => None,
}
_ => false,
},
_ => false,
}
acp::SessionConfigKind::Boolean(_) if !apply_boolean_defaults => {
return None;
}
acp::SessionConfigKind::Boolean(_) => default_value
.as_bool()
.map(acp::SessionConfigOptionValue::boolean),
_ => None,
};
if is_valid {
if let Some(value_to_apply) = value_to_apply {
let initial_value = match &config_option.kind {
acp::SessionConfigKind::Select(select) => {
Some(select.current_value.clone())
acp::SessionConfigOptionValue::value_id(
select.current_value.clone(),
)
}
_ => None,
acp::SessionConfigKind::Boolean(boolean) => {
acp::SessionConfigOptionValue::boolean(boolean.current_value)
}
_ => return None,
};
Some((config_option.id.clone(), default_value, initial_value))
Some((config_option.id.clone(), value_to_apply, initial_value))
} else {
log::warn!(
"`{}` is not a valid value for config option `{}` in {}",
@ -1302,7 +1350,7 @@ impl AcpConnection {
for (config_id, default_value, initial_value) in defaults_to_apply {
cx.spawn({
let default_value_id = acp::SessionConfigValueId::new(default_value.clone());
let default_value_for_request = default_value.clone();
let session_id = session_id.clone();
let config_id_clone = config_id.clone();
let config_opts = config_options.clone();
@ -1312,19 +1360,29 @@ impl AcpConnection {
.send_request(acp::SetSessionConfigOptionRequest::new(
session_id,
config_id_clone.clone(),
default_value_id,
default_value_for_request,
))
.block_task()
.await
.log_err();
if result.is_none() {
if let Some(initial) = initial_value {
let mut opts = config_opts.borrow_mut();
if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) {
if let acp::SessionConfigKind::Select(select) = &mut opt.kind {
select.current_value = initial;
let mut opts = config_opts.borrow_mut();
if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) {
match (&mut opt.kind, &initial_value) {
(
acp::SessionConfigKind::Select(select),
acp::SessionConfigOptionValue::ValueId { value },
) => {
select.current_value = value.clone();
}
(
acp::SessionConfigKind::Boolean(boolean),
acp::SessionConfigOptionValue::Boolean { value },
) => {
boolean.current_value = *value;
}
_ => {}
}
}
}
@ -1334,8 +1392,20 @@ impl AcpConnection {
let mut opts = config_options.borrow_mut();
if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) {
if let acp::SessionConfigKind::Select(select) = &mut opt.kind {
select.current_value = acp::SessionConfigValueId::new(default_value);
match (&mut opt.kind, &default_value) {
(
acp::SessionConfigKind::Select(select),
acp::SessionConfigOptionValue::ValueId { value },
) => {
select.current_value = value.clone();
}
(
acp::SessionConfigKind::Boolean(boolean),
acp::SessionConfigOptionValue::Boolean { value },
) => {
boolean.current_value = *value;
}
_ => {}
}
}
}
@ -2469,11 +2539,12 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use feature_flags::FeatureFlag as _;
use settings::Settings as _;
#[test]
fn cursor_client_capabilities_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID));
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false);
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@ -2488,7 +2559,7 @@ mod tests {
#[test]
fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@ -2496,6 +2567,26 @@ mod tests {
assert!(!meta.contains_key(PARAMETERIZED_MODEL_PICKER_META_KEY));
}
#[test]
fn client_capabilities_include_boolean_config_options_when_supported() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), true);
assert!(
capabilities
.session
.and_then(|session| session.config_options)
.and_then(|config_options| config_options.boolean)
.is_some()
);
}
#[test]
fn client_capabilities_omit_boolean_config_options_when_unsupported() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
assert!(capabilities.session.is_none());
}
#[test]
fn terminal_auth_task_builds_spawn_from_prebuilt_command() {
let command = AgentServerCommand {
@ -2943,7 +3034,7 @@ mod tests {
default_mode: Some("manual".to_string()),
default_config_options: HashMap::from_iter([(
"mode".to_string(),
"manual".to_string(),
AgentConfigOptionValue::from("manual"),
)]),
favorite_config_option_values: HashMap::default(),
}
@ -2959,8 +3050,13 @@ mod tests {
Some(acp::SessionModeId::new("manual"))
);
assert_eq!(
harness.connection.defaults.config_option("mode").as_deref(),
Some("manual")
harness
.connection
.defaults
.config_option("mode")
.as_ref()
.and_then(AgentConfigOptionValue::as_value_id),
Some("manual"),
);
cx.update(|cx| {
@ -2975,6 +3071,192 @@ mod tests {
assert_eq!(harness.connection.defaults.config_option("mode"), None);
}
#[gpui::test]
async fn default_config_options_skip_boolean_defaults_when_acp_beta_is_disabled(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| init_settings_with_acp_beta_override(false, cx));
let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await;
connection.defaults.set(
None,
HashMap::from_iter([
(
"web_search".to_string(),
AgentConfigOptionValue::Boolean(true),
),
("mode".to_string(), AgentConfigOptionValue::from("manual")),
]),
);
let config_options = Rc::new(RefCell::new(vec![
acp::SessionConfigOption::boolean("web_search", "Web Search", false),
acp::SessionConfigOption::select(
"mode",
"Mode",
"auto",
vec![
acp::SessionConfigSelectOption::new("auto", "Auto"),
acp::SessionConfigSelectOption::new("manual", "Manual"),
],
),
]));
let mut async_cx = cx.to_async();
connection.apply_default_config_options(
&acp::SessionId::new("session-config-defaults"),
&config_options,
&mut async_cx,
);
drop(async_cx);
cx.run_until_parked();
let requests = set_config_requests
.lock()
.expect("set config requests mutex poisoned");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].config_id, acp::SessionConfigId::new("mode"));
assert_eq!(
requests[0].value,
acp::SessionConfigOptionValue::value_id("manual")
);
let options = config_options.borrow();
assert!(
matches!(&options[0].kind, acp::SessionConfigKind::Boolean(boolean) if !boolean.current_value)
);
assert!(
matches!(&options[1].kind, acp::SessionConfigKind::Select(select) if select.current_value == acp::SessionConfigValueId::new("manual"))
);
}
#[gpui::test]
async fn default_config_options_apply_boolean_defaults_when_acp_beta_is_enabled(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| init_settings_with_acp_beta_override(true, cx));
let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await;
connection.defaults.set(
None,
HashMap::from_iter([(
"web_search".to_string(),
AgentConfigOptionValue::Boolean(true),
)]),
);
let config_options = Rc::new(RefCell::new(vec![acp::SessionConfigOption::boolean(
"web_search",
"Web Search",
false,
)]));
let mut async_cx = cx.to_async();
connection.apply_default_config_options(
&acp::SessionId::new("session-config-defaults"),
&config_options,
&mut async_cx,
);
drop(async_cx);
cx.run_until_parked();
let requests = set_config_requests
.lock()
.expect("set config requests mutex poisoned");
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].config_id,
acp::SessionConfigId::new("web_search")
);
assert_eq!(
requests[0].value,
acp::SessionConfigOptionValue::boolean(true)
);
let options = config_options.borrow();
assert!(
matches!(&options[0].kind, acp::SessionConfigKind::Boolean(boolean) if boolean.current_value)
);
}
fn init_settings_with_acp_beta_override(enabled: bool, cx: &mut App) {
let mut store = settings::SettingsStore::test(cx);
store.register_setting::<feature_flags::FeatureFlagsSettings>();
store.update_user_settings(cx, |content| {
content.feature_flags.get_or_insert_default().insert(
AcpBetaFeatureFlag::NAME.to_string(),
if enabled { "on" } else { "off" }.to_string(),
);
});
cx.set_global(store);
cx.update_flags(false, Vec::new());
}
async fn connect_config_defaults_test_agent(
cx: &mut gpui::TestAppContext,
) -> (
AcpConnection,
Arc<Mutex<Vec<acp::SetSessionConfigOptionRequest>>>,
) {
let set_config_requests = Arc::new(Mutex::new(Vec::new()));
let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex();
cx.background_spawn(
Agent
.builder()
.name("config-defaults-test-agent")
.on_receive_request(
{
let set_config_requests = set_config_requests.clone();
async move |req: acp::SetSessionConfigOptionRequest, responder, _cx| {
set_config_requests
.lock()
.expect("set config requests mutex poisoned")
.push(req);
responder.respond(acp::SetSessionConfigOptionResponse::new(Vec::new()))
}
},
agent_client_protocol::on_receive_request!(),
)
.connect_to(agent_transport),
)
.detach();
let (connection_tx, connection_rx) = futures::channel::oneshot::channel();
let client_io_task = cx.background_spawn(async move {
Client
.builder()
.name("config-defaults-test-client")
.connect_with(
client_transport,
move |connection: ConnectionTo<Agent>| async move {
connection_tx.send(connection).ok();
futures::future::pending::<Result<(), acp::Error>>().await
},
)
.await
.ok();
});
let client_conn = connection_rx
.await
.expect("failed to receive ACP connection");
let sessions = Rc::new(RefCell::new(HashMap::default()));
let connection = cx.update(|cx| {
AcpConnection::new_for_test(
client_conn,
sessions,
acp::AgentCapabilities::default(),
WeakEntity::new_invalid(),
client_io_task,
Task::ready(()),
cx,
)
});
(connection, set_config_requests)
}
#[gpui::test]
async fn session_list_delete_sends_session_delete_when_supported(
cx: &mut gpui::TestAppContext,
@ -3766,7 +4048,7 @@ impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions {
fn set_config_option(
&self,
config_id: acp::SessionConfigId,
value: acp::SessionConfigValueId,
value: acp::SessionConfigOptionValue,
cx: &mut App,
) -> Task<Result<Vec<acp::SessionConfigOption>>> {
let connection = self.connection.clone();

View file

@ -15,7 +15,7 @@ use acp_thread::AgentConnection;
use agent_client_protocol::schema::v1 as acp_schema;
use anyhow::Result;
use gpui::{App, AppContext, Entity, Task};
use settings::SettingsStore;
use settings::{AgentConfigOptionValue, SettingsStore};
use std::{any::Any, rc::Rc, sync::Arc};
#[cfg(any(test, feature = "test-support"))]
@ -71,14 +71,14 @@ pub trait AgentServer: Send {
) {
}
fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option<String> {
fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option<AgentConfigOptionValue> {
None
}
fn set_default_config_option(
&self,
_config_id: &str,
_value_id: Option<&str>,
_value: Option<AgentConfigOptionValue>,
_fs: Arc<dyn Fs>,
_cx: &mut App,
) {

View file

@ -10,7 +10,7 @@ use project::{
Project,
agent_server_store::{AgentId, AllAgentServersSettings},
};
use settings::{SettingsStore, update_settings_file};
use settings::{AgentConfigOptionValue, SettingsStore, update_settings_file};
use std::{rc::Rc, sync::Arc};
use ui::IconName;
@ -142,7 +142,7 @@ impl AgentServer for CustomAgentServer {
});
}
fn default_config_option(&self, config_id: &str, cx: &App) -> Option<String> {
fn default_config_option(&self, config_id: &str, cx: &App) -> Option<AgentConfigOptionValue> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
@ -152,19 +152,18 @@ impl AgentServer for CustomAgentServer {
settings
.as_ref()
.and_then(|s| s.default_config_option(config_id).map(|s| s.to_string()))
.and_then(|s| s.default_config_option(config_id).cloned())
}
fn set_default_config_option(
&self,
config_id: &str,
value_id: Option<&str>,
value: Option<AgentConfigOptionValue>,
fs: Arc<dyn Fs>,
cx: &mut App,
) {
let agent_id = self.agent_id();
let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.to_string());
update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings
.agent_servers
@ -181,7 +180,7 @@ impl AgentServer for CustomAgentServer {
default_config_options,
..
} => {
if let Some(value) = value_id.clone() {
if let Some(value) = value {
default_config_options.insert(config_id.clone(), value);
} else {
default_config_options.remove(&config_id);

View file

@ -5,6 +5,7 @@ use agent_client_protocol::schema::v1 as acp;
use agent_servers::AgentServer;
use collections::HashSet;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use fs::Fs;
use fuzzy::StringMatchCandidate;
use gpui::{
@ -13,10 +14,10 @@ use gpui::{
use ordered_float::OrderedFloat;
use picker::popover_menu::PickerPopoverMenu;
use picker::{Picker, PickerDelegate};
use settings::SettingsStore;
use settings::{AgentConfigOptionValue, SettingsStore};
use ui::{
ElevationIndex, IconButton, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip,
prelude::*,
ElevationIndex, IconButton, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Switch,
SwitchLabelPosition, ToggleState, Tooltip, prelude::*,
};
use unicode_segmentation::UnicodeSegmentation;
use util::ResultExt as _;
@ -79,7 +80,9 @@ impl ConfigOptionsView {
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(config_id) = self.first_config_option_id(category) else {
let Some(config_id) = self.first_config_option_id_matching(category, |option| {
matches!(&option.kind, acp::SessionConfigKind::Select(_))
}) else {
return false;
};
@ -87,11 +90,7 @@ impl ConfigOptionsView {
return false;
};
selector.update(cx, |selector, cx| {
selector.toggle_picker(window, cx);
});
true
selector.update(cx, |selector, cx| selector.toggle_picker(window, cx))
}
pub fn cycle_category_option(
@ -100,17 +99,21 @@ impl ConfigOptionsView {
favorites_only: bool,
cx: &mut Context<Self>,
) -> bool {
let Some(config_id) = self.first_config_option_id(category) else {
let render_boolean_config_options = should_render_boolean_config_options(cx);
let Some(config_id) = self.first_config_option_id_matching(category, |option| {
Self::can_cycle_config_option(option, favorites_only, render_boolean_config_options)
}) 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(),
Some(next_value.0.as_ref()),
default_value,
self.fs.clone(),
cx,
);
@ -129,17 +132,30 @@ impl ConfigOptionsView {
true
}
fn first_config_option_id(
fn first_config_option_id_matching(
&self,
category: acp::SessionConfigOptionCategory,
predicate: impl Fn(&acp::SessionConfigOption) -> bool,
) -> Option<acp::SessionConfigId> {
self.config_options
.config_options()
.into_iter()
.find(|option| option.category.as_ref() == Some(&category))
.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,
render_boolean_config_options: bool,
) -> bool {
match &option.kind {
acp::SessionConfigKind::Select(_) => true,
acp::SessionConfigKind::Boolean(_) => !favorites_only && render_boolean_config_options,
_ => false,
}
}
fn selector_for_config_id(
&self,
config_id: &acp::SessionConfigId,
@ -156,35 +172,57 @@ impl ConfigOptionsView {
config_id: &acp::SessionConfigId,
favorites_only: bool,
cx: &mut Context<Self>,
) -> Option<acp::SessionConfigValueId> {
let mut options = extract_options(&self.config_options, config_id);
if options.is_empty() {
return None;
}
) -> Option<acp::SessionConfigOptionValue> {
let option = self
.config_options
.config_options()
.into_iter()
.find(|option| &option.id == config_id)?;
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;
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 || !should_render_boolean_config_options(cx) {
None
} else {
Some(acp::SessionConfigOptionValue::boolean(
!boolean.current_value,
))
}
}
_ => None,
}
let current_value = get_current_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(options[next_index].value.clone())
}
fn config_option_ids(
@ -256,8 +294,10 @@ impl Render for ConfigOptionsView {
struct ConfigOptionSelector {
config_options: Rc<dyn AgentSessionConfigOptions>,
config_id: acp::SessionConfigId,
picker_handle: PopoverMenuHandle<Picker<ConfigOptionPickerDelegate>>,
picker: Entity<Picker<ConfigOptionPickerDelegate>>,
agent_server: Rc<dyn AgentServer>,
fs: Arc<dyn Fs>,
picker_handle: Option<PopoverMenuHandle<Picker<ConfigOptionPickerDelegate>>>,
picker: Option<Entity<Picker<ConfigOptionPickerDelegate>>>,
setting_value: bool,
}
@ -270,21 +310,26 @@ impl ConfigOptionSelector {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let option_count = config_options
let current_option = config_options
.config_options()
.iter()
.find(|opt| opt.id == config_id)
.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 = {
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();
cx.new(move |picker_cx| {
let picker = cx.new(move |picker_cx| {
let delegate = ConfigOptionPickerDelegate::new(
config_options,
config_id,
@ -301,13 +346,18 @@ impl ConfigOptionSelector {
}
.show_scrollbar(true)
.initial_width(rems(20.))
})
});
(Some(PopoverMenuHandle::default()), Some(picker))
} else {
(None, None)
};
Self {
config_options,
config_id,
picker_handle: PopoverMenuHandle::default(),
agent_server,
fs,
picker_handle,
picker,
setting_value: false,
}
@ -324,8 +374,13 @@ impl ConfigOptionSelector {
&self.config_id
}
fn toggle_picker(&self, window: &mut Window, cx: &mut Context<Self>) {
self.picker_handle.toggle(window, cx);
fn toggle_picker(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if let Some(picker_handle) = &self.picker_handle {
picker_handle.toggle(window, cx);
true
} else {
false
}
}
fn current_value_name(&self) -> String {
@ -346,7 +401,10 @@ impl ConfigOptionSelector {
self.config_options
.config_options()
.into_iter()
.find(|option| option.category.as_ref() == Some(category))
.find(|option| {
option.category.as_ref() == Some(category)
&& matches!(&option.kind, acp::SessionConfigKind::Select(_))
})
.is_some_and(|option| option.id == self.config_id)
}
@ -358,7 +416,11 @@ impl ConfigOptionSelector {
.disabled(true);
};
let icon = if self.picker_handle.is_deployed() {
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
@ -390,88 +452,173 @@ impl Render for ConfigOptionSelector {
return div().into_any_element();
};
let trigger_button = self.render_trigger_button(window, cx);
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 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<SharedString> = option.description.map(Into::into);
let trigger_button = self.render_trigger_button(window, cx);
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 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<SharedString> =
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()
}
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),
));
}
_ => {}
acp::SessionConfigKind::Boolean(boolean) => {
if !should_render_boolean_config_options(cx) {
return div().into_any_element();
}
}
content.into_any()
});
PickerPopoverMenu::new(
self.picker.clone(),
trigger_button,
tooltip,
gpui::Anchor::BottomRight,
cx,
)
.with_handle(self.picker_handle.clone())
.render(window, cx)
.into_any_element()
let option_id = option.id.clone();
let option_name: SharedString = option.name.clone().into();
let option_description: Option<SharedString> =
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)
.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(),
}
}
}
@ -516,7 +663,7 @@ impl ConfigOptionPickerDelegate {
let all_options = extract_options(&config_options, &config_id);
let filtered_entries = options_to_picker_entries(&all_options, &favorites);
let current_value = get_current_value(&config_options, &config_id);
let current_value = get_current_select_value(&config_options, &config_id);
let selected_index = current_value
.and_then(|current| {
filtered_entries.iter().position(|entry| {
@ -554,7 +701,7 @@ impl ConfigOptionPickerDelegate {
}
fn current_value(&self) -> Option<acp::SessionConfigValueId> {
get_current_value(&self.config_options, &self.config_id)
get_current_select_value(&self.config_options, &self.config_id)
}
}
@ -639,13 +786,13 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
{
self.agent_server.set_default_config_option(
self.config_id.0.as_ref(),
Some(option.value.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(),
option.value.clone(),
acp::SessionConfigOptionValue::value_id(option.value.clone()),
cx,
);
@ -816,7 +963,7 @@ fn extract_options(
}
}
fn get_current_value(
fn get_current_select_value(
config_options: &Rc<dyn AgentSessionConfigOptions>,
config_id: &acp::SessionConfigId,
) -> Option<acp::SessionConfigValueId> {
@ -830,6 +977,24 @@ fn get_current_value(
})
}
fn setting_value_for_config_option_value(
value: &acp::SessionConfigOptionValue,
) -> Option<AgentConfigOptionValue> {
match value {
acp::SessionConfigOptionValue::ValueId { value } => {
Some(AgentConfigOptionValue::ValueId(value.0.to_string()))
}
acp::SessionConfigOptionValue::Boolean { value } => {
Some(AgentConfigOptionValue::Boolean(*value))
}
_ => None,
}
}
fn should_render_boolean_config_options(cx: &App) -> bool {
cx.has_flag::<AcpBetaFeatureFlag>()
}
fn options_to_picker_entries(
options: &[ConfigOptionValue],
favorites: &HashSet<acp::SessionConfigValueId>,
@ -945,8 +1110,9 @@ fn count_config_options(option: &acp::SessionConfigOption) -> usize {
mod tests {
use super::*;
use acp_thread::AgentConnection;
use feature_flags::FeatureFlag as _;
use fs::FakeFs;
use gpui::TestAppContext;
use gpui::{TestAppContext, UpdateGlobal};
use parking_lot::Mutex;
use project::{AgentId, Project};
use std::{any::Any, cell::RefCell};
@ -988,17 +1154,219 @@ mod tests {
assert_eq!(
agent_server.saved_defaults.lock().as_slice(),
&[("mode".to_string(), Some("manual".to_string()))]
&[(
"mode".to_string(),
Some(AgentConfigOptionValue::ValueId("manual".to_string()))
)]
);
assert_eq!(
config_options.set_values.borrow().as_slice(),
&[("mode".to_string(), "manual".to_string())]
&[(
"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<dyn Fs> = FakeFs::new(cx.executor());
cx.update(|cx| {
init_feature_flag_settings(cx);
set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx);
let config_options: Rc<dyn AgentSessionConfigOptions> = config_options.clone();
let agent_server: Rc<dyn AgentServer> = 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_hidden_boolean_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::ModelConfig),
]));
let fs: Arc<dyn Fs> = FakeFs::new(cx.executor());
cx.update(|cx| {
init_feature_flag_settings(cx);
set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx);
let config_options: Rc<dyn AgentSessionConfigOptions> = config_options.clone();
let agent_server: Rc<dyn AgentServer> = 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!(agent_server.saved_defaults.lock().is_empty());
assert!(config_options.set_values.borrow().is_empty());
}
#[gpui::test]
fn cycling_category_skips_hidden_boolean_config_option(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<dyn Fs> = FakeFs::new(cx.executor());
cx.update(|cx| {
init_feature_flag_settings(cx);
set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx);
let config_options: Rc<dyn AgentSessionConfigOptions> = config_options.clone();
let agent_server: Rc<dyn AgentServer> = 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(),
&[(
"model".to_string(),
Some(AgentConfigOptionValue::ValueId("large".to_string()))
)]
);
assert_eq!(
config_options.set_values.borrow().as_slice(),
&[(
"model".to_string(),
acp::SessionConfigOptionValue::value_id("large")
)]
);
}
#[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<dyn Fs> = FakeFs::new(cx.executor());
let cx = cx.add_empty_window();
let view = cx.update({
move |window, cx| {
let config_options: Rc<dyn AgentSessionConfigOptions> = config_options;
let agent_server: Rc<dyn AgentServer> = 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);
}
#[gpui::test]
fn boolean_config_option_rendering_is_beta_gated(cx: &mut TestAppContext) {
cx.update(|cx| {
init_feature_flag_settings(cx);
cx.update_flags(false, Vec::new());
set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx);
assert!(!should_render_boolean_config_options(cx));
set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx);
assert!(should_render_boolean_config_options(cx));
});
}
#[derive(Default)]
struct TestAgentServer {
saved_defaults: Arc<Mutex<Vec<(String, Option<String>)>>>,
saved_defaults: Arc<Mutex<Vec<(String, Option<AgentConfigOptionValue>)>>>,
}
fn init_feature_flag_settings(cx: &mut App) {
let store = SettingsStore::test(cx);
cx.set_global(store);
SettingsStore::update_global(cx, |store, _| {
store.register_setting::<feature_flags::FeatureFlagsSettings>();
});
cx.update_flags(false, Vec::new());
}
fn set_feature_flag_override(name: &str, value: &str, cx: &mut App) {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert(name.to_string(), value.to_string());
});
});
}
impl AgentServer for TestAgentServer {
@ -1026,20 +1394,19 @@ mod tests {
fn set_default_config_option(
&self,
config_id: &str,
value_id: Option<&str>,
value: Option<AgentConfigOptionValue>,
_fs: Arc<dyn Fs>,
_cx: &mut App,
) {
self.saved_defaults.lock().push((
config_id.to_string(),
value_id.map(|value| value.to_string()),
));
self.saved_defaults
.lock()
.push((config_id.to_string(), value));
}
}
struct TestSessionConfigOptions {
options: RefCell<Vec<acp::SessionConfigOption>>,
set_values: RefCell<Vec<(String, String)>>,
set_values: RefCell<Vec<(String, acp::SessionConfigOptionValue)>>,
}
impl TestSessionConfigOptions {
@ -1059,19 +1426,31 @@ mod tests {
fn set_config_option(
&self,
config_id: acp::SessionConfigId,
value: acp::SessionConfigValueId,
value: acp::SessionConfigOptionValue,
_cx: &mut App,
) -> Task<anyhow::Result<Vec<acp::SessionConfigOption>>> {
self.set_values
.borrow_mut()
.push((config_id.0.to_string(), value.0.to_string()));
.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)
&& let acp::SessionConfigKind::Select(select) = &mut option.kind
{
select.current_value = value;
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()
};

View file

@ -1160,7 +1160,6 @@ impl ConversationView {
// Check for config options first
// Config options take precedence over legacy mode/model selectors
// (feature flag gating happens at the data layer)
let config_options_provider = connection.session_config_options(&session_id, cx);
let config_options_view;

View file

@ -21,7 +21,7 @@ use rpc::{AnyProtoClient, TypedEnvelope, proto};
use schemars::JsonSchema;
use semver::Version;
use serde::{Deserialize, Serialize};
use settings::{RegisterSetting, SettingsStore, update_settings_file};
use settings::{AgentConfigOptionValue, RegisterSetting, SettingsStore, update_settings_file};
use sha2::{Digest, Sha256};
use url::Url;
use util::{ResultExt as _, debug_panic};
@ -1524,10 +1524,10 @@ pub enum CustomAgentServerSettings {
default_mode: Option<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to value ID.
/// This is a map from config option ID to the default value for that option.
///
/// Default: {}
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
@ -1548,10 +1548,10 @@ pub enum CustomAgentServerSettings {
default_mode: Option<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to value ID.
/// This is a map from config option ID to the default value for that option.
///
/// Default: {}
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
@ -1576,7 +1576,7 @@ impl CustomAgentServerSettings {
}
}
pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
pub fn default_config_option(&self, config_id: &str) -> Option<&AgentConfigOptionValue> {
match self {
CustomAgentServerSettings::Custom {
default_config_options,
@ -1585,7 +1585,7 @@ impl CustomAgentServerSettings {
| CustomAgentServerSettings::Registry {
default_config_options,
..
} => default_config_options.get(config_id).map(|s| s.as_str()),
} => default_config_options.get(config_id),
}
}

View file

@ -667,6 +667,56 @@ impl std::ops::DerefMut for AllAgentServersSettings {
}
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq, Eq)]
#[serde(untagged)]
pub enum AgentConfigOptionValue {
ValueId(String),
Boolean(bool),
}
impl AgentConfigOptionValue {
pub fn as_value_id(&self) -> Option<&str> {
match self {
Self::ValueId(value) => Some(value),
Self::Boolean(_) => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Boolean(value) => Some(*value),
Self::ValueId(_) => None,
}
}
}
impl std::fmt::Display for AgentConfigOptionValue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ValueId(value) => formatter.write_str(value),
Self::Boolean(value) => value.fmt(formatter),
}
}
}
impl From<String> for AgentConfigOptionValue {
fn from(value: String) -> Self {
Self::ValueId(value)
}
}
impl From<&str> for AgentConfigOptionValue {
fn from(value: &str) -> Self {
Self::ValueId(value.to_string())
}
}
impl From<bool> for AgentConfigOptionValue {
fn from(value: bool) -> Self {
Self::Boolean(value)
}
}
#[with_fallible_options]
#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
@ -687,11 +737,11 @@ pub enum CustomAgentServerSettings {
default_mode: Option<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to value ID.
/// This is a map from config option ID to the default value for that option.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
@ -716,11 +766,11 @@ pub enum CustomAgentServerSettings {
default_mode: Option<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to value ID.
/// This is a map from config option ID to the default value for that option.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
@ -863,6 +913,46 @@ impl std::fmt::Display for ToolPermissionMode {
mod tests {
use super::*;
#[test]
fn agent_config_option_value_serializes_value_id_as_string() {
let value = AgentConfigOptionValue::from("manual");
assert_eq!(
serde_json::to_value(&value).expect("serialize value id"),
serde_json::json!("manual")
);
assert_eq!(
serde_json::from_value::<AgentConfigOptionValue>(serde_json::json!("manual"))
.expect("deserialize value id"),
AgentConfigOptionValue::ValueId("manual".to_string())
);
}
#[test]
fn agent_config_option_value_serializes_boolean_as_boolean() {
let value = AgentConfigOptionValue::Boolean(true);
assert_eq!(
serde_json::to_value(&value).expect("serialize boolean"),
serde_json::json!(true)
);
assert_eq!(
serde_json::from_value::<AgentConfigOptionValue>(serde_json::json!(true))
.expect("deserialize boolean"),
AgentConfigOptionValue::Boolean(true)
);
}
#[test]
fn agent_config_option_value_merge_replaces_existing_value() {
use crate::merge_from::MergeFrom as _;
let mut value = AgentConfigOptionValue::ValueId("manual".to_string());
value.merge_from(&AgentConfigOptionValue::Boolean(true));
assert_eq!(value, AgentConfigOptionValue::Boolean(true));
}
#[test]
fn test_set_tool_default_permission_creates_structure() {
let mut settings = AgentSettingsContent::default();

View file

@ -9,7 +9,9 @@ use gpui::{
};
use itertools::Itertools as _;
use project::agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource};
use settings::{CustomAgentServerSettings, SettingsStore, update_settings_file};
use settings::{
AgentConfigOptionValue, CustomAgentServerSettings, SettingsStore, update_settings_file,
};
use ui::{
AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, ContextMenuEntry,
Divider, DividerColor, PopoverMenu, Tooltip, prelude::*,
@ -366,7 +368,7 @@ pub(crate) struct CustomAgentForm {
/// Advanced fields not surfaced by the form. They're preserved verbatim so
/// editing the basic settings doesn't drop a user's hand-written config.
default_mode: Option<String>,
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
favorite_config_option_values: HashMap<String, Vec<String>>,
/// Stable handles for the Cancel/Save buttons so they can render a focus
/// ring. `Filled`/`Subtle` buttons only get a subtle `focus_visible`
@ -812,7 +814,7 @@ struct CustomAgentFormValues {
args: String,
env: Vec<(String, String)>,
default_mode: Option<String>,
default_config_options: HashMap<String, String>,
default_config_options: HashMap<String, AgentConfigOptionValue>,
favorite_config_option_values: HashMap<String, Vec<String>>,
}
@ -1157,7 +1159,7 @@ mod tests {
let mut values = values();
values.default_mode = Some("ask".into());
values.default_config_options =
HashMap::from_iter([("opt".to_string(), "val".to_string())]);
HashMap::from_iter([("opt".to_string(), AgentConfigOptionValue::from("val"))]);
let (_, _, content) = build_settings_from_values(values).unwrap();
match content {
@ -1168,8 +1170,10 @@ mod tests {
} => {
assert_eq!(default_mode.as_deref(), Some("ask"));
assert_eq!(
default_config_options.get("opt").map(String::as_str),
Some("val")
default_config_options
.get("opt")
.and_then(AgentConfigOptionValue::as_value_id),
Some("val"),
);
}
_ => panic!("expected a custom agent"),