From bc24ffe863233bd8e48374169a43ed39ce833059 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sat, 27 Dec 2025 23:10:37 +0100 Subject: [PATCH] acp: Beta support for Session Config Options (#45751) Adds beta support for the ACP draft feature of Session Config Options: https://agentclientprotocol.com/rfds/session-config-options Release Notes: - N/A --- Cargo.lock | 1 + crates/acp_thread/src/acp_thread.rs | 5 + crates/acp_thread/src/connection.rs | 28 + crates/agent_servers/Cargo.toml | 1 + crates/agent_servers/src/acp.rs | 206 ++++- crates/agent_servers/src/agent_servers.rs | 39 +- crates/agent_servers/src/claude.rs | 104 ++- crates/agent_servers/src/codex.rs | 104 ++- crates/agent_servers/src/custom.rs | 165 +++- crates/agent_servers/src/e2e_tests.rs | 14 +- crates/agent_servers/src/gemini.rs | 14 +- crates/agent_ui/src/acp.rs | 1 + crates/agent_ui/src/acp/config_options.rs | 772 ++++++++++++++++++ crates/agent_ui/src/acp/thread_view.rs | 103 ++- crates/agent_ui/src/agent_configuration.rs | 2 + crates/agent_ui/src/agent_diff.rs | 3 +- crates/feature_flags/src/flags.rs | 6 + crates/project/src/agent_server_store.rs | 68 ++ crates/settings/src/settings_content/agent.rs | 42 + 19 files changed, 1615 insertions(+), 63 deletions(-) create mode 100644 crates/agent_ui/src/acp/config_options.rs diff --git a/Cargo.lock b/Cargo.lock index bfee934d608669aad0f591eb5fbcd99f519e2955..616e898868398306b4ccae0b67f3072d44bccf95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,7 @@ dependencies = [ "client", "collections", "env_logger 0.11.8", + "feature_flags", "fs", "futures 0.3.31", "gpui", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 80b69b7e010f22fb311d1924ed4d6946ef6a0484..ef515b58e5d4d2547753cfad845a0e74dca87e72 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -884,6 +884,7 @@ pub enum AcpThreadEvent { Refusal, AvailableCommandsUpdated(Vec), ModeUpdated(acp::SessionModeId), + ConfigOptionsUpdated(Vec), } impl EventEmitter for AcpThread {} @@ -1193,6 +1194,10 @@ impl AcpThread { current_mode_id, .. }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)), + acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate { + config_options, + .. + }) => cx.emit(AcpThreadEvent::ConfigOptionsUpdated(config_options)), _ => {} } Ok(()) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index fa15a339f7db67a90144b645177f1146c97334b4..63b4b46104cb721cf40febba4ed9e856d27f7115 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -86,6 +86,14 @@ pub trait AgentConnection { None } + fn session_config_options( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + None + } + fn into_any(self: Rc) -> Rc; } @@ -125,6 +133,26 @@ pub trait AgentSessionModes { fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task>; } +pub trait AgentSessionConfigOptions { + /// Get all current config options with their state + fn config_options(&self) -> Vec; + + /// Set a config option value + /// Returns the full updated list of config options + fn set_config_option( + &self, + config_id: acp::SessionConfigId, + value: acp::SessionConfigValueId, + cx: &mut App, + ) -> Task>>; + + /// Whenever the config options are updated the receiver will be notified. + /// Optional for agents that don't update their config options dynamically. + fn watch(&self, _cx: &mut App) -> Option> { + None + } +} + #[derive(Debug)] pub struct AuthRequired { pub description: Option, diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 9a04fb763d0a5abe5d7dcf8df87048dd4cfd51dc..20c7dd705895802bd6b413033331cb190941567b 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -21,6 +21,7 @@ acp_tools.workspace = true acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true +feature_flags.workspace = true anyhow.workspace = true async-trait.workspace = true client.workspace = true diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index e99855fe8a7241468e93f01fe6c7b6fee161f600..1ab0fa3cb54c747bb47e34b7a8b05a925cefab5b 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -4,6 +4,7 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; use anyhow::anyhow; use collections::HashMap; +use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::AsyncBufReadExt as _; use futures::io::BufReader; use project::Project; @@ -38,6 +39,7 @@ pub struct AcpConnection { agent_capabilities: acp::AgentCapabilities, default_mode: Option, default_model: Option, + default_config_options: HashMap, root_dir: PathBuf, // NB: Don't move this into the wait_task, since we need to ensure the process is // killed on drop (setting kill_on_drop on the command seems to not always work). @@ -47,11 +49,29 @@ pub struct AcpConnection { _stderr_task: Task>, } +struct ConfigOptions { + config_options: Rc>>, + tx: Rc>>, + rx: watch::Receiver<()>, +} + +impl ConfigOptions { + fn new(config_options: Rc>>) -> Self { + let (tx, rx) = watch::channel(()); + Self { + config_options, + tx: Rc::new(RefCell::new(tx)), + rx, + } + } +} + pub struct AcpSession { thread: WeakEntity, suppress_abort_err: bool, models: Option>>, session_modes: Option>>, + config_options: Option, } pub async fn connect( @@ -60,6 +80,7 @@ pub async fn connect( root_dir: &Path, default_mode: Option, default_model: Option, + default_config_options: HashMap, is_remote: bool, cx: &mut AsyncApp, ) -> Result> { @@ -69,6 +90,7 @@ pub async fn connect( root_dir, default_mode, default_model, + default_config_options, is_remote, cx, ) @@ -85,6 +107,7 @@ impl AcpConnection { root_dir: &Path, default_mode: Option, default_model: Option, + default_config_options: HashMap, is_remote: bool, cx: &mut AsyncApp, ) -> Result { @@ -217,6 +240,7 @@ impl AcpConnection { agent_capabilities: response.agent_capabilities, default_mode, default_model, + default_config_options, _io_task: io_task, _wait_task: wait_task, _stderr_task: stderr_task, @@ -256,6 +280,7 @@ impl AgentConnection for AcpConnection { let sessions = self.sessions.clone(); let default_mode = self.default_mode.clone(); let default_model = self.default_model.clone(); + let default_config_options = self.default_config_options.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); let mcp_servers = if project.read(cx).is_local() { @@ -322,8 +347,21 @@ impl AgentConnection for AcpConnection { } })?; - let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes))); - let models = response.models.map(|models| Rc::new(RefCell::new(models))); + let use_config_options = cx.update(|cx| cx.has_flag::())?; + + // Config options take precedence over legacy modes/models + let (modes, models, config_options) = if use_config_options && let Some(opts) = response.config_options { + ( + None, + None, + Some(Rc::new(RefCell::new(opts))), + ) + } else { + // Fall back to legacy modes/models + let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes))); + let models = response.models.map(|models| Rc::new(RefCell::new(models))); + (modes, models, None) + }; if let Some(default_mode) = default_mode { if let Some(modes) = modes.as_ref() { @@ -411,6 +449,92 @@ impl AgentConnection for AcpConnection { } } + if let Some(config_opts) = config_options.as_ref() { + let defaults_to_apply: Vec<_> = { + let config_opts_ref = config_opts.borrow(); + config_opts_ref + .iter() + .filter_map(|config_option| { + let default_value = default_config_options.get(&*config_option.id.0)?; + + 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())), + _ => false, + }, + _ => false, + }; + + if is_valid { + let initial_value = match &config_option.kind { + acp::SessionConfigKind::Select(select) => { + Some(select.current_value.clone()) + } + _ => None, + }; + Some((config_option.id.clone(), default_value.clone(), initial_value)) + } else { + log::warn!( + "`{}` is not a valid value for config option `{}` in {}", + default_value, + config_option.id.0, + name + ); + None + } + }) + .collect() + }; + + for (config_id, default_value, initial_value) in defaults_to_apply { + cx.spawn({ + let default_value_id = acp::SessionConfigValueId::new(default_value.clone()); + let session_id = response.session_id.clone(); + let config_id_clone = config_id.clone(); + let config_opts = config_opts.clone(); + let conn = conn.clone(); + async move |_| { + let result = conn + .set_session_config_option( + acp::SetSessionConfigOptionRequest::new( + session_id, + config_id_clone.clone(), + default_value_id, + ), + ) + .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; + } + } + } + } + } + }) + .detach(); + + let mut opts = config_opts.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); + } + } + } + } + let session_id = response.session_id; let action_log = cx.new(|_| ActionLog::new(project.clone()))?; let thread = cx.new(|cx| { @@ -432,6 +556,7 @@ impl AgentConnection for AcpConnection { suppress_abort_err: false, session_modes: modes, models, + config_options: config_options.map(|opts| ConfigOptions::new(opts)) }; sessions.borrow_mut().insert(session_id, session); @@ -567,6 +692,25 @@ impl AgentConnection for AcpConnection { } } + fn session_config_options( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + let sessions = self.sessions.borrow(); + let session = sessions.get(session_id)?; + + let config_opts = session.config_options.as_ref()?; + + Some(Rc::new(AcpSessionConfigOptions { + session_id: session_id.clone(), + connection: self.connection.clone(), + state: config_opts.config_options.clone(), + watch_tx: config_opts.tx.clone(), + watch_rx: config_opts.rx.clone(), + }) as _) + } + fn into_any(self: Rc) -> Rc { self } @@ -685,6 +829,49 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { } } +struct AcpSessionConfigOptions { + session_id: acp::SessionId, + connection: Rc, + state: Rc>>, + watch_tx: Rc>>, + watch_rx: watch::Receiver<()>, +} + +impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions { + fn config_options(&self) -> Vec { + self.state.borrow().clone() + } + + fn set_config_option( + &self, + config_id: acp::SessionConfigId, + value: acp::SessionConfigValueId, + cx: &mut App, + ) -> Task>> { + let connection = self.connection.clone(); + let session_id = self.session_id.clone(); + let state = self.state.clone(); + + let watch_tx = self.watch_tx.clone(); + + cx.foreground_executor().spawn(async move { + let response = connection + .set_session_config_option(acp::SetSessionConfigOptionRequest::new( + session_id, config_id, value, + )) + .await?; + + *state.borrow_mut() = response.config_options.clone(); + watch_tx.borrow_mut().send(()).ok(); + Ok(response.config_options) + }) + } + + fn watch(&self, _cx: &mut App) -> Option> { + Some(self.watch_rx.clone()) + } +} + struct ClientDelegate { sessions: Rc>>, cx: AsyncApp, @@ -778,6 +965,21 @@ impl acp::Client for ClientDelegate { } } + if let acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate { + config_options, + .. + }) = ¬ification.update + { + if let Some(opts) = &session.config_options { + *opts.config_options.borrow_mut() = config_options.clone(); + opts.tx.borrow_mut().send(()).ok(); + } else { + log::error!( + "Got a `ConfigOptionUpdate` notification, but the agent didn't specify `config_options` during session setup." + ); + } + } + // Clone so we can inspect meta both before and after handing off to the thread let update_clone = notification.update.clone(); diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index c6e66688dd6af6748a97dcd4569827fd7fa32493..6877c93342c22db3426bcf497fd9d45fe15c14ef 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -4,15 +4,13 @@ mod codex; mod custom; mod gemini; -use collections::HashSet; - #[cfg(any(test, feature = "test-support"))] pub mod e2e_tests; pub use claude::*; use client::ProxySettings; pub use codex::*; -use collections::HashMap; +use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; pub use gemini::*; @@ -67,7 +65,7 @@ pub trait AgentServer: Send { fn into_any(self: Rc) -> Rc; - fn default_mode(&self, _cx: &mut App) -> Option { + fn default_mode(&self, _cx: &App) -> Option { None } @@ -79,7 +77,7 @@ pub trait AgentServer: Send { ) { } - fn default_model(&self, _cx: &mut App) -> Option { + fn default_model(&self, _cx: &App) -> Option { None } @@ -95,6 +93,37 @@ pub trait AgentServer: Send { HashSet::default() } + fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option { + None + } + + fn set_default_config_option( + &self, + _config_id: &str, + _value_id: Option<&str>, + _fs: Arc, + _cx: &mut App, + ) { + } + + fn favorite_config_option_value_ids( + &self, + _config_id: &agent_client_protocol::SessionConfigId, + _cx: &mut App, + ) -> HashSet { + HashSet::default() + } + + fn toggle_favorite_config_option_value( + &self, + _config_id: agent_client_protocol::SessionConfigId, + _value_id: agent_client_protocol::SessionConfigValueId, + _should_be_favorite: bool, + _fs: Arc, + _cx: &App, + ) { + } + fn toggle_favorite_model( &self, _model_id: agent_client_protocol::ModelId, diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 30ef39af953e66fc983c3d7f189042b6577e84c0..bea8cd5b18e3e74a297f695057e5a18a030d068c 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -31,7 +31,7 @@ impl AgentServer for ClaudeCode { ui::IconName::AiClaude } - fn default_mode(&self, cx: &mut App) -> Option { + fn default_mode(&self, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).claude.clone() }); @@ -52,7 +52,7 @@ impl AgentServer for ClaudeCode { }); } - fn default_model(&self, cx: &mut App) -> Option { + fn default_model(&self, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).claude.clone() }); @@ -115,6 +115,97 @@ impl AgentServer for ClaudeCode { }); } + fn default_config_option(&self, config_id: &str, cx: &App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_config_options.get(config_id).cloned()) + } + + fn set_default_config_option( + &self, + config_id: &str, + value_id: Option<&str>, + fs: Arc, + cx: &mut App, + ) { + let config_id = config_id.to_string(); + let value_id = value_id.map(|s| s.to_string()); + update_settings_file(fs, cx, move |settings, _| { + let config_options = &mut settings + .agent_servers + .get_or_insert_default() + .claude + .get_or_insert_default() + .default_config_options; + + if let Some(value) = value_id.clone() { + config_options.insert(config_id.clone(), value); + } else { + config_options.remove(&config_id); + } + }); + } + + fn favorite_config_option_value_ids( + &self, + config_id: &acp::SessionConfigId, + cx: &mut App, + ) -> HashSet { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); + + settings + .as_ref() + .and_then(|s| s.favorite_config_option_values.get(config_id.0.as_ref())) + .map(|values| { + values + .iter() + .cloned() + .map(acp::SessionConfigValueId::new) + .collect() + }) + .unwrap_or_default() + } + + fn toggle_favorite_config_option_value( + &self, + config_id: acp::SessionConfigId, + value_id: acp::SessionConfigValueId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + let config_id = config_id.to_string(); + let value_id = value_id.to_string(); + + update_settings_file(fs, cx, move |settings, _| { + let favorites = &mut settings + .agent_servers + .get_or_insert_default() + .claude + .get_or_insert_default() + .favorite_config_option_values; + + let entry = favorites.entry(config_id.clone()).or_insert_with(Vec::new); + + if should_be_favorite { + if !entry.iter().any(|v| v == &value_id) { + entry.push(value_id.clone()); + } + } else { + entry.retain(|v| v != &value_id); + if entry.is_empty() { + favorites.remove(&config_id); + } + } + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -128,6 +219,14 @@ impl AgentServer for ClaudeCode { let extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); + let default_config_options = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .claude + .as_ref() + .map(|s| s.default_config_options.clone()) + .unwrap_or_default() + }); cx.spawn(async move |cx| { let (command, root_dir, login) = store @@ -150,6 +249,7 @@ impl AgentServer for ClaudeCode { root_dir.as_ref(), default_mode, default_model, + default_config_options, is_remote, cx, ) diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index 15dc4688294da979149f331ea52e8163ae5d3093..5585cbc2cb22c5be0695a9d7b83b92a0c0a09383 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -32,7 +32,7 @@ impl AgentServer for Codex { ui::IconName::AiOpenAi } - fn default_mode(&self, cx: &mut App) -> Option { + fn default_mode(&self, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).codex.clone() }); @@ -53,7 +53,7 @@ impl AgentServer for Codex { }); } - fn default_model(&self, cx: &mut App) -> Option { + fn default_model(&self, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).codex.clone() }); @@ -116,6 +116,97 @@ impl AgentServer for Codex { }); } + fn default_config_option(&self, config_id: &str, cx: &App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_config_options.get(config_id).cloned()) + } + + fn set_default_config_option( + &self, + config_id: &str, + value_id: Option<&str>, + fs: Arc, + cx: &mut App, + ) { + let config_id = config_id.to_string(); + let value_id = value_id.map(|s| s.to_string()); + update_settings_file(fs, cx, move |settings, _| { + let config_options = &mut settings + .agent_servers + .get_or_insert_default() + .codex + .get_or_insert_default() + .default_config_options; + + if let Some(value) = value_id.clone() { + config_options.insert(config_id.clone(), value); + } else { + config_options.remove(&config_id); + } + }); + } + + fn favorite_config_option_value_ids( + &self, + config_id: &acp::SessionConfigId, + cx: &mut App, + ) -> HashSet { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + }); + + settings + .as_ref() + .and_then(|s| s.favorite_config_option_values.get(config_id.0.as_ref())) + .map(|values| { + values + .iter() + .cloned() + .map(acp::SessionConfigValueId::new) + .collect() + }) + .unwrap_or_default() + } + + fn toggle_favorite_config_option_value( + &self, + config_id: acp::SessionConfigId, + value_id: acp::SessionConfigValueId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + let config_id = config_id.to_string(); + let value_id = value_id.to_string(); + + update_settings_file(fs, cx, move |settings, _| { + let favorites = &mut settings + .agent_servers + .get_or_insert_default() + .codex + .get_or_insert_default() + .favorite_config_option_values; + + let entry = favorites.entry(config_id.clone()).or_insert_with(Vec::new); + + if should_be_favorite { + if !entry.iter().any(|v| v == &value_id) { + entry.push(value_id.clone()); + } + } else { + entry.retain(|v| v != &value_id); + if entry.is_empty() { + favorites.remove(&config_id); + } + } + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -129,6 +220,14 @@ impl AgentServer for Codex { let extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); + let default_config_options = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .codex + .as_ref() + .map(|s| s.default_config_options.clone()) + .unwrap_or_default() + }); cx.spawn(async move |cx| { let (command, root_dir, login) = store @@ -152,6 +251,7 @@ impl AgentServer for Codex { root_dir.as_ref(), default_mode, default_model, + default_config_options, is_remote, cx, ) diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index f58948190266adeb0e5509d2ec2825a48d503f50..34b604417b5d195c553ee03cd13ad088577b47b5 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -30,7 +30,7 @@ impl AgentServer for CustomAgentServer { IconName::Terminal } - fn default_mode(&self, cx: &mut App) -> Option { + fn default_mode(&self, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) @@ -44,6 +44,86 @@ impl AgentServer for CustomAgentServer { .and_then(|s| s.default_mode().map(acp::SessionModeId::new)) } + fn favorite_config_option_value_ids( + &self, + config_id: &acp::SessionConfigId, + cx: &mut App, + ) -> HashSet { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .cloned() + }); + + settings + .as_ref() + .and_then(|s| s.favorite_config_option_values(config_id.0.as_ref())) + .map(|values| { + values + .iter() + .cloned() + .map(acp::SessionConfigValueId::new) + .collect() + }) + .unwrap_or_default() + } + + fn toggle_favorite_config_option_value( + &self, + config_id: acp::SessionConfigId, + value_id: acp::SessionConfigValueId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + let name = self.name(); + let config_id = config_id.to_string(); + let value_id = value_id.to_string(); + + update_settings_file(fs, cx, move |settings, _| { + let settings = settings + .agent_servers + .get_or_insert_default() + .custom + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + }); + + match settings { + settings::CustomAgentServerSettings::Custom { + favorite_config_option_values, + .. + } + | settings::CustomAgentServerSettings::Extension { + favorite_config_option_values, + .. + } => { + let entry = favorite_config_option_values + .entry(config_id.clone()) + .or_insert_with(Vec::new); + + if should_be_favorite { + if !entry.iter().any(|v| v == &value_id) { + entry.push(value_id.clone()); + } + } else { + entry.retain(|v| v != &value_id); + if entry.is_empty() { + favorite_config_option_values.remove(&config_id); + } + } + } + } + }); + } + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { let name = self.name(); update_settings_file(fs, cx, move |settings, _| { @@ -56,6 +136,8 @@ impl AgentServer for CustomAgentServer { default_model: None, default_mode: None, favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), }); match settings { @@ -67,7 +149,7 @@ impl AgentServer for CustomAgentServer { }); } - fn default_model(&self, cx: &mut App) -> Option { + fn default_model(&self, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) @@ -93,6 +175,8 @@ impl AgentServer for CustomAgentServer { default_model: None, default_mode: None, favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), }); match settings { @@ -142,6 +226,8 @@ impl AgentServer for CustomAgentServer { default_model: None, default_mode: None, favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), }); let favorite_models = match settings { @@ -164,6 +250,63 @@ impl AgentServer for CustomAgentServer { }); } + fn default_config_option(&self, config_id: &str, cx: &App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .cloned() + }); + + settings + .as_ref() + .and_then(|s| s.default_config_option(config_id).map(|s| s.to_string())) + } + + fn set_default_config_option( + &self, + config_id: &str, + value_id: Option<&str>, + fs: Arc, + cx: &mut App, + ) { + let name = self.name(); + let config_id = config_id.to_string(); + let value_id = value_id.map(|s| s.to_string()); + update_settings_file(fs, cx, move |settings, _| { + let settings = settings + .agent_servers + .get_or_insert_default() + .custom + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + }); + + match settings { + settings::CustomAgentServerSettings::Custom { + default_config_options, + .. + } + | settings::CustomAgentServerSettings::Extension { + default_config_options, + .. + } => { + if let Some(value) = value_id.clone() { + default_config_options.insert(config_id.clone(), value); + } else { + default_config_options.remove(&config_id); + } + } + } + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -175,6 +318,23 @@ impl AgentServer for CustomAgentServer { let is_remote = delegate.project.read(cx).is_via_remote_server(); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); + let default_config_options = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .map(|s| match s { + project::agent_server_store::CustomAgentServerSettings::Custom { + default_config_options, + .. + } + | project::agent_server_store::CustomAgentServerSettings::Extension { + default_config_options, + .. + } => default_config_options.clone(), + }) + .unwrap_or_default() + }); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); cx.spawn(async move |cx| { @@ -200,6 +360,7 @@ impl AgentServer for CustomAgentServer { root_dir.as_ref(), default_mode, default_model, + default_config_options, is_remote, cx, ) diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 975bb7e373c5ddd13df6c6bc951096fced668355..99d6c3a6b85c1c292d96abd6770b25042d06ef71 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -455,22 +455,12 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { project::agent_server_store::AllAgentServersSettings { claude: Some(BuiltinAgentServerSettings { path: Some("claude-code-acp".into()), - args: None, - env: None, - ignore_system_version: None, - default_mode: None, - default_model: None, - favorite_models: vec![], + ..Default::default() }), gemini: Some(crate::gemini::tests::local_command().into()), codex: Some(BuiltinAgentServerSettings { path: Some("codex-acp".into()), - args: None, - env: None, - ignore_system_version: None, - default_mode: None, - default_model: None, - favorite_models: vec![], + ..Default::default() }), custom: collections::HashMap::default(), }, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 5fea74746aec73f3ea7bb33562244e4a6eea5ba7..7e3371467afb35715cad7d92d20ab6df55170028 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -4,9 +4,10 @@ use std::{any::Any, path::Path}; use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; use anyhow::{Context as _, Result}; -use gpui::{App, SharedString, Task}; +use gpui::{App, AppContext as _, SharedString, Task}; use language_models::provider::google::GoogleLanguageModelProvider; -use project::agent_server_store::GEMINI_NAME; +use project::agent_server_store::{AllAgentServersSettings, GEMINI_NAME}; +use settings::SettingsStore; #[derive(Clone)] pub struct Gemini; @@ -33,6 +34,14 @@ impl AgentServer for Gemini { let mut extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); + let default_config_options = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .gemini + .as_ref() + .map(|s| s.default_config_options.clone()) + .unwrap_or_default() + }); cx.spawn(async move |cx| { extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); @@ -65,6 +74,7 @@ impl AgentServer for Gemini { root_dir.as_ref(), default_mode, default_model, + default_config_options, is_remote, cx, ) diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 7a740c2dc4b9fbc769aa847347a0aa56d5f51934..5c892f00f56b057e9272a84a776e1fa771768efe 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,3 +1,4 @@ +mod config_options; mod entry_view_state; mod message_editor; mod mode_selector; diff --git a/crates/agent_ui/src/acp/config_options.rs b/crates/agent_ui/src/acp/config_options.rs new file mode 100644 index 0000000000000000000000000000000000000000..f2e1aefc3f9c37cfaa79257aed2e6a7312ac1d22 --- /dev/null +++ b/crates/agent_ui/src/acp/config_options.rs @@ -0,0 +1,772 @@ +use std::{cmp::Reverse, rc::Rc, sync::Arc}; + +use acp_thread::AgentSessionConfigOptions; +use agent_client_protocol as acp; +use agent_servers::AgentServer; +use collections::HashSet; +use fs::Fs; +use fuzzy::StringMatchCandidate; +use gpui::{ + BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*, +}; +use ordered_float::OrderedFloat; +use picker::popover_menu::PickerPopoverMenu; +use picker::{Picker, PickerDelegate}; +use settings::SettingsStore; +use ui::{ + ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, +}; +use util::ResultExt as _; + +use crate::ui::HoldForDefault; + +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.refresh_selectors_if_needed(window, cx); + cx.notify(); + }) + .log_err(); + } + } + }); + + Self { + config_options, + selectors, + agent_server, + fs, + config_option_ids, + _refresh_task: refresh_task, + } + } + + fn config_option_ids( + config_options: &Rc, + ) -> Vec { + config_options + .config_options() + .into_iter() + .map(|option| option.id) + .collect() + } + + fn refresh_selectors_if_needed(&mut self, window: &mut Window, cx: &mut Context) { + let current_ids = Self::config_option_ids(&self.config_options); + if current_ids != self.config_option_ids { + self.config_option_ids = current_ids; + self.rebuild_selectors(window, cx); + } + } + + fn rebuild_selectors(&mut self, window: &mut Window, cx: &mut Context) { + 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() + .gap_1() + .children(self.selectors.iter().cloned()) + .into_any_element() + } +} + +struct ConfigOptionSelector { + config_options: Rc, + config_id: acp::SessionConfigId, + picker_handle: PopoverMenuHandle>, + picker: Entity>, + 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 option_count = config_options + .config_options() + .iter() + .find(|opt| opt.id == config_id) + .map(count_config_options) + .unwrap_or(0); + + let is_searchable = option_count >= PICKER_THRESHOLD; + + let picker = { + 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 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) + .width(rems(20.)) + .max_height(Some(rems(20.).into())) + }) + }; + + Self { + config_options, + config_id, + picker_handle: PopoverMenuHandle::default(), + picker, + setting_value: false, + } + } + + fn current_option(&self) -> Option { + self.config_options + .config_options() + .into_iter() + .find(|opt| opt.id == self.config_id) + } + + 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 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 icon = if self.picker_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + Button::new( + ElementId::Name(format!("config-option-{}", option.id.0).into()), + self.current_value_name(), + ) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(icon) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_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(); + }; + + let trigger_button = self.render_trigger_button(window, cx); + + let option_name = option.name.clone(); + let option_description: Option = option.description.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), + ); + } + content.into_any() + }); + + PickerPopoverMenu::new( + self.picker.clone(), + trigger_button, + tooltip, + gpui::Corner::BottomRight, + cx, + ) + .with_handle(self.picker_handle.clone()) + .render(window, cx) + .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, bool)>, + 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_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_value(&self.config_options, &self.config_id) + } +} + +impl PickerDelegate for ConfigOptionPickerDelegate { + type ListItem = AnyElement; + + 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( + &mut 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) + { + if window.modifiers().secondary() { + let default_value = self + .agent_server + .default_config_option(self.config_id.0.as_ref(), cx); + let is_default = default_value.as_deref() == Some(&*option.value.0); + + self.agent_server.set_default_config_option( + self.config_id.0.as_ref(), + if is_default { + None + } else { + Some(option.value.0.as_ref()) + }, + self.fs.clone(), + cx, + ); + } + + let task = self.config_options.set_config_option( + self.config_id.clone(), + 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 default_value = self + .agent_server + .default_config_option(self.config_id.0.as_ref(), cx); + let is_default = default_value.as_deref() == Some(&*option.value.0); + + 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(), is_default)); + } 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_hover_slot(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, is_default)| { + let description = description.clone(); + let is_default = *is_default; + + ui::DocumentationAside::new( + ui::DocumentationSide::Left, + ui::DocumentationEdge::Top, + Rc::new(move |_| { + v_flex() + .gap_1() + .child(Label::new(description.clone())) + .child(HoldForDefault::new(is_default)) + .into_any_element() + }), + ) + }) + } +} + +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_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 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, + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 6ea9102695cf1206ba9b6ec40d2615dce85f8fd4..3859f5921bf2a99410ac883a649721ce2b6f9989 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -56,6 +56,7 @@ use workspace::{CollaboratorId, NewTerminal, Workspace}; use zed_actions::agent::{Chat, ToggleModelSelector}; use zed_actions::assistant::OpenRulesLibrary; +use super::config_options::ConfigOptionsView; use super::entry_view_state::EntryViewState; use crate::acp::AcpModelSelectorPopover; use crate::acp::ModeSelector; @@ -272,6 +273,7 @@ pub struct AcpThreadView { message_editor: Entity, focus_handle: FocusHandle, model_selector: Option>, + config_options_view: Option>, profile_selector: Option>, notifications: Vec>, notification_subscriptions: HashMap, Vec>, @@ -430,6 +432,7 @@ impl AcpThreadView { login: None, message_editor, model_selector: None, + config_options_view: None, profile_selector: None, notifications: Vec::new(), notification_subscriptions: HashMap::default(), @@ -614,42 +617,64 @@ impl AcpThreadView { AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); - this.model_selector = thread + // 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 = thread .read(cx) .connection() - .model_selector(thread.read(cx).session_id()) - .map(|selector| { - let agent_server = this.agent.clone(); - let fs = this.project.read(cx).fs().clone(); - cx.new(|cx| { - AcpModelSelectorPopover::new( - selector, - agent_server, - fs, - PopoverMenuHandle::default(), - this.focus_handle(cx), - window, - cx, - ) - }) - }); + .session_config_options(thread.read(cx).session_id(), cx); + + let mode_selector; + if let Some(config_options) = config_options_provider { + // Use config options - don't create mode_selector or model_selector + let agent_server = this.agent.clone(); + let fs = this.project.read(cx).fs().clone(); + this.config_options_view = Some(cx.new(|cx| { + ConfigOptionsView::new(config_options, agent_server, fs, window, cx) + })); + this.model_selector = None; + mode_selector = None; + } else { + // Fall back to legacy mode/model selectors + this.config_options_view = None; + this.model_selector = thread + .read(cx) + .connection() + .model_selector(thread.read(cx).session_id()) + .map(|selector| { + let agent_server = this.agent.clone(); + let fs = this.project.read(cx).fs().clone(); + cx.new(|cx| { + AcpModelSelectorPopover::new( + selector, + agent_server, + fs, + PopoverMenuHandle::default(), + this.focus_handle(cx), + window, + cx, + ) + }) + }); - let mode_selector = thread - .read(cx) - .connection() - .session_modes(thread.read(cx).session_id(), cx) - .map(|session_modes| { - let fs = this.project.read(cx).fs().clone(); - let focus_handle = this.focus_handle(cx); - cx.new(|_cx| { - ModeSelector::new( - session_modes, - this.agent.clone(), - fs, - focus_handle, - ) - }) - }); + mode_selector = thread + .read(cx) + .connection() + .session_modes(thread.read(cx).session_id(), cx) + .map(|session_modes| { + let fs = this.project.read(cx).fs().clone(); + let focus_handle = this.focus_handle(cx); + cx.new(|_cx| { + ModeSelector::new( + session_modes, + this.agent.clone(), + fs, + focus_handle, + ) + }) + }); + } let mut subscriptions = vec![ cx.subscribe_in(&thread, window, Self::handle_thread_event), @@ -1522,6 +1547,10 @@ impl AcpThreadView { // The connection keeps track of the mode cx.notify(); } + AcpThreadEvent::ConfigOptionsUpdated(_) => { + // The watch task in ConfigOptionsView handles rebuilding selectors + cx.notify(); + } } cx.notify(); } @@ -4417,8 +4446,12 @@ impl AcpThreadView { .gap_1() .children(self.render_token_usage(cx)) .children(self.profile_selector.clone()) - .children(self.mode_selector().cloned()) - .children(self.model_selector.clone()) + // Either config_options_view OR (mode_selector + model_selector) + .children(self.config_options_view.clone()) + .when(self.config_options_view.is_none(), |this| { + this.children(self.mode_selector().cloned()) + .children(self.model_selector.clone()) + }) .child(self.render_send_button(cx)), ), ) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fb2d50863c002cba0c7b0d63c2a5a4cc73224b4d..9abe5c2f4f4f7fc1c4e2ae92390cc4cb74528151 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1371,6 +1371,8 @@ async fn open_new_agent_servers_entry_in_settings_editor( default_mode: None, default_model: None, favorite_models: vec![], + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), }, ); } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 8d2bd534cac9b429cc1789e6ae0d5e7cd2f6e617..b1db9837482ec9a377615d5041a0a688e7afa884 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1363,7 +1363,8 @@ impl AgentDiff { | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::AvailableCommandsUpdated(_) | AcpThreadEvent::Retry(_) - | AcpThreadEvent::ModeUpdated(_) => {} + | AcpThreadEvent::ModeUpdated(_) + | AcpThreadEvent::ConfigOptionsUpdated(_) => {} } } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 1768e43d1d0a88433d61c6390f912377c2ba55e3..5d624e1ea5a6df9168dd17eeb4d825502ca0c136 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -23,3 +23,9 @@ pub struct AgentV2FeatureFlag; impl FeatureFlag for AgentV2FeatureFlag { const NAME: &'static str = "agent-v2"; } + +pub struct AcpBetaFeatureFlag; + +impl FeatureFlag for AcpBetaFeatureFlag { + const NAME: &'static str = "acp-beta"; +} diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 8829befaac7f21a1262ce0bf1410bce71546a3ed..077e4d387d50cbb685bdee551612d7a5545e1ac4 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1869,6 +1869,8 @@ pub struct BuiltinAgentServerSettings { pub default_mode: Option, pub default_model: Option, pub favorite_models: Vec, + pub default_config_options: HashMap, + pub favorite_config_option_values: HashMap>, } impl BuiltinAgentServerSettings { @@ -1893,6 +1895,8 @@ impl From for BuiltinAgentServerSettings { default_mode: value.default_mode, default_model: value.default_model, favorite_models: value.favorite_models, + default_config_options: value.default_config_options, + favorite_config_option_values: value.favorite_config_option_values, } } } @@ -1928,6 +1932,18 @@ pub enum CustomAgentServerSettings { /// /// Default: [] favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + favorite_config_option_values: HashMap>, }, Extension { /// The default mode to use for this agent. @@ -1946,6 +1962,18 @@ pub enum CustomAgentServerSettings { /// /// Default: [] favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + favorite_config_option_values: HashMap>, }, } @@ -1983,6 +2011,34 @@ impl CustomAgentServerSettings { } => favorite_models, } } + + pub fn default_config_option(&self, config_id: &str) -> Option<&str> { + match self { + CustomAgentServerSettings::Custom { + default_config_options, + .. + } + | CustomAgentServerSettings::Extension { + default_config_options, + .. + } => default_config_options.get(config_id).map(|s| s.as_str()), + } + } + + pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> { + match self { + CustomAgentServerSettings::Custom { + favorite_config_option_values, + .. + } + | CustomAgentServerSettings::Extension { + favorite_config_option_values, + .. + } => favorite_config_option_values + .get(config_id) + .map(|v| v.as_slice()), + } + } } impl From for CustomAgentServerSettings { @@ -1995,6 +2051,8 @@ impl From for CustomAgentServerSettings { default_mode, default_model, favorite_models, + default_config_options, + favorite_config_option_values, } => CustomAgentServerSettings::Custom { command: AgentServerCommand { path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()), @@ -2004,15 +2062,21 @@ impl From for CustomAgentServerSettings { default_mode, default_model, favorite_models, + default_config_options, + favorite_config_option_values, }, settings::CustomAgentServerSettings::Extension { default_mode, default_model, + default_config_options, favorite_models, + favorite_config_option_values, } => CustomAgentServerSettings::Extension { default_mode, default_model, + default_config_options, favorite_models, + favorite_config_option_values, }, } } @@ -2339,6 +2403,8 @@ mod extension_agent_tests { default_mode: None, default_model: None, favorite_models: vec![], + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), }; let BuiltinAgentServerSettings { path, .. } = settings.into(); @@ -2356,6 +2422,8 @@ mod extension_agent_tests { default_mode: None, default_model: None, favorite_models: vec![], + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), }; let converted: CustomAgentServerSettings = settings.into(); diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index 2abf00777af2308c4c2339bd180db47fb3d5e02f..8473305dcd320e3c1a8d5d87018a8565a3e40649 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -370,6 +370,20 @@ pub struct BuiltinAgentServerSettings { /// Default: [] #[serde(default)] pub favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + #[serde(default)] + pub default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + #[serde(default)] + pub favorite_config_option_values: HashMap>, } #[with_fallible_options] @@ -401,6 +415,20 @@ pub enum CustomAgentServerSettings { /// Default: [] #[serde(default)] favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + #[serde(default)] + default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + #[serde(default)] + favorite_config_option_values: HashMap>, }, Extension { /// The default mode to use for this agent. @@ -422,5 +450,19 @@ pub enum CustomAgentServerSettings { /// Default: [] #[serde(default)] favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + #[serde(default)] + default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + #[serde(default)] + favorite_config_option_values: HashMap>, }, }