acp: Beta support for Session Config Options (#45751)

Ben Brandt created

Adds beta support for the ACP draft feature of Session Config Options:
https://agentclientprotocol.com/rfds/session-config-options

Release Notes:

- N/A

Change summary

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, 1,615 insertions(+), 63 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -268,6 +268,7 @@ dependencies = [
  "client",
  "collections",
  "env_logger 0.11.8",
+ "feature_flags",
  "fs",
  "futures 0.3.31",
  "gpui",

crates/acp_thread/src/acp_thread.rs πŸ”—

@@ -884,6 +884,7 @@ pub enum AcpThreadEvent {
     Refusal,
     AvailableCommandsUpdated(Vec<acp::AvailableCommand>),
     ModeUpdated(acp::SessionModeId),
+    ConfigOptionsUpdated(Vec<acp::SessionConfigOption>),
 }
 
 impl EventEmitter<AcpThreadEvent> 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(())

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<Rc<dyn AgentSessionConfigOptions>> {
+        None
+    }
+
     fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
 }
 
@@ -125,6 +133,26 @@ pub trait AgentSessionModes {
     fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task<Result<()>>;
 }
 
+pub trait AgentSessionConfigOptions {
+    /// Get all current config options with their state
+    fn config_options(&self) -> Vec<acp::SessionConfigOption>;
+
+    /// 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<Result<Vec<acp::SessionConfigOption>>>;
+
+    /// 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<watch::Receiver<()>> {
+        None
+    }
+}
+
 #[derive(Debug)]
 pub struct AuthRequired {
     pub description: Option<String>,

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

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<acp::SessionModeId>,
     default_model: Option<acp::ModelId>,
+    default_config_options: HashMap<String, String>,
     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<Result<()>>,
 }
 
+struct ConfigOptions {
+    config_options: Rc<RefCell<Vec<acp::SessionConfigOption>>>,
+    tx: Rc<RefCell<watch::Sender<()>>>,
+    rx: watch::Receiver<()>,
+}
+
+impl ConfigOptions {
+    fn new(config_options: Rc<RefCell<Vec<acp::SessionConfigOption>>>) -> Self {
+        let (tx, rx) = watch::channel(());
+        Self {
+            config_options,
+            tx: Rc::new(RefCell::new(tx)),
+            rx,
+        }
+    }
+}
+
 pub struct AcpSession {
     thread: WeakEntity<AcpThread>,
     suppress_abort_err: bool,
     models: Option<Rc<RefCell<acp::SessionModelState>>>,
     session_modes: Option<Rc<RefCell<acp::SessionModeState>>>,
+    config_options: Option<ConfigOptions>,
 }
 
 pub async fn connect(
@@ -60,6 +80,7 @@ pub async fn connect(
     root_dir: &Path,
     default_mode: Option<acp::SessionModeId>,
     default_model: Option<acp::ModelId>,
+    default_config_options: HashMap<String, String>,
     is_remote: bool,
     cx: &mut AsyncApp,
 ) -> Result<Rc<dyn AgentConnection>> {
@@ -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<acp::SessionModeId>,
         default_model: Option<acp::ModelId>,
+        default_config_options: HashMap<String, String>,
         is_remote: bool,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
@@ -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::<AcpBetaFeatureFlag>())?;
+
+            // 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<Rc<dyn acp_thread::AgentSessionConfigOptions>> {
+        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<Self>) -> Rc<dyn Any> {
         self
     }
@@ -685,6 +829,49 @@ impl acp_thread::AgentModelSelector for AcpModelSelector {
     }
 }
 
+struct AcpSessionConfigOptions {
+    session_id: acp::SessionId,
+    connection: Rc<acp::ClientSideConnection>,
+    state: Rc<RefCell<Vec<acp::SessionConfigOption>>>,
+    watch_tx: Rc<RefCell<watch::Sender<()>>>,
+    watch_rx: watch::Receiver<()>,
+}
+
+impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions {
+    fn config_options(&self) -> Vec<acp::SessionConfigOption> {
+        self.state.borrow().clone()
+    }
+
+    fn set_config_option(
+        &self,
+        config_id: acp::SessionConfigId,
+        value: acp::SessionConfigValueId,
+        cx: &mut App,
+    ) -> Task<Result<Vec<acp::SessionConfigOption>>> {
+        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<watch::Receiver<()>> {
+        Some(self.watch_rx.clone())
+    }
+}
+
 struct ClientDelegate {
     sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
     cx: AsyncApp,
@@ -778,6 +965,21 @@ impl acp::Client for ClientDelegate {
             }
         }
 
+        if let acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate {
+            config_options,
+            ..
+        }) = &notification.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();
 

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<Self>) -> Rc<dyn Any>;
 
-    fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> {
+    fn default_mode(&self, _cx: &App) -> Option<agent_client_protocol::SessionModeId> {
         None
     }
 
@@ -79,7 +77,7 @@ pub trait AgentServer: Send {
     ) {
     }
 
-    fn default_model(&self, _cx: &mut App) -> Option<agent_client_protocol::ModelId> {
+    fn default_model(&self, _cx: &App) -> Option<agent_client_protocol::ModelId> {
         None
     }
 
@@ -95,6 +93,37 @@ pub trait AgentServer: Send {
         HashSet::default()
     }
 
+    fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option<String> {
+        None
+    }
+
+    fn set_default_config_option(
+        &self,
+        _config_id: &str,
+        _value_id: Option<&str>,
+        _fs: Arc<dyn Fs>,
+        _cx: &mut App,
+    ) {
+    }
+
+    fn favorite_config_option_value_ids(
+        &self,
+        _config_id: &agent_client_protocol::SessionConfigId,
+        _cx: &mut App,
+    ) -> HashSet<agent_client_protocol::SessionConfigValueId> {
+        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<dyn Fs>,
+        _cx: &App,
+    ) {
+    }
+
     fn toggle_favorite_model(
         &self,
         _model_id: agent_client_protocol::ModelId,

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<acp::SessionModeId> {
+    fn default_mode(&self, cx: &App) -> Option<acp::SessionModeId> {
         let settings = cx.read_global(|settings: &SettingsStore, _| {
             settings.get::<AllAgentServersSettings>(None).claude.clone()
         });
@@ -52,7 +52,7 @@ impl AgentServer for ClaudeCode {
         });
     }
 
-    fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
+    fn default_model(&self, cx: &App) -> Option<acp::ModelId> {
         let settings = cx.read_global(|settings: &SettingsStore, _| {
             settings.get::<AllAgentServersSettings>(None).claude.clone()
         });
@@ -115,6 +115,97 @@ impl AgentServer for ClaudeCode {
         });
     }
 
+    fn default_config_option(&self, config_id: &str, cx: &App) -> Option<String> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(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<dyn Fs>,
+        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<acp::SessionConfigValueId> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(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<dyn Fs>,
+        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::<AllAgentServersSettings>(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,
             )

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<acp::SessionModeId> {
+    fn default_mode(&self, cx: &App) -> Option<acp::SessionModeId> {
         let settings = cx.read_global(|settings: &SettingsStore, _| {
             settings.get::<AllAgentServersSettings>(None).codex.clone()
         });
@@ -53,7 +53,7 @@ impl AgentServer for Codex {
         });
     }
 
-    fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
+    fn default_model(&self, cx: &App) -> Option<acp::ModelId> {
         let settings = cx.read_global(|settings: &SettingsStore, _| {
             settings.get::<AllAgentServersSettings>(None).codex.clone()
         });
@@ -116,6 +116,97 @@ impl AgentServer for Codex {
         });
     }
 
+    fn default_config_option(&self, config_id: &str, cx: &App) -> Option<String> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(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<dyn Fs>,
+        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<acp::SessionConfigValueId> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(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<dyn Fs>,
+        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::<AllAgentServersSettings>(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,
             )

crates/agent_servers/src/custom.rs πŸ”—

@@ -30,7 +30,7 @@ impl AgentServer for CustomAgentServer {
         IconName::Terminal
     }
 
-    fn default_mode(&self, cx: &mut App) -> Option<acp::SessionModeId> {
+    fn default_mode(&self, cx: &App) -> Option<acp::SessionModeId> {
         let settings = cx.read_global(|settings: &SettingsStore, _| {
             settings
                 .get::<AllAgentServersSettings>(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<acp::SessionConfigValueId> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings
+                .get::<AllAgentServersSettings>(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<dyn Fs>,
+        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<acp::SessionModeId>, fs: Arc<dyn Fs>, 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<acp::ModelId> {
+    fn default_model(&self, cx: &App) -> Option<acp::ModelId> {
         let settings = cx.read_global(|settings: &SettingsStore, _| {
             settings
                 .get::<AllAgentServersSettings>(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<String> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings
+                .get::<AllAgentServersSettings>(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<dyn Fs>,
+        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::<AllAgentServersSettings>(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,
             )

crates/agent_servers/src/e2e_tests.rs πŸ”—

@@ -455,22 +455,12 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
             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(),
             },

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::<AllAgentServersSettings>(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,
             )

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<dyn AgentSessionConfigOptions>,
+    selectors: Vec<Entity<ConfigOptionSelector>>,
+    agent_server: Rc<dyn AgentServer>,
+    fs: Arc<dyn Fs>,
+    config_option_ids: Vec<acp::SessionConfigId>,
+    _refresh_task: Task<()>,
+}
+
+impl ConfigOptionsView {
+    pub fn new(
+        config_options: Rc<dyn AgentSessionConfigOptions>,
+        agent_server: Rc<dyn AgentServer>,
+        fs: Arc<dyn Fs>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<dyn AgentSessionConfigOptions>,
+    ) -> Vec<acp::SessionConfigId> {
+        config_options
+            .config_options()
+            .into_iter()
+            .map(|option| option.id)
+            .collect()
+    }
+
+    fn refresh_selectors_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        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>) {
+        self.selectors = Self::build_selectors(
+            &self.config_options,
+            &self.agent_server,
+            &self.fs,
+            window,
+            cx,
+        );
+        cx.notify();
+    }
+
+    fn build_selectors(
+        config_options: &Rc<dyn AgentSessionConfigOptions>,
+        agent_server: &Rc<dyn AgentServer>,
+        fs: &Arc<dyn Fs>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<Entity<ConfigOptionSelector>> {
+        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<Self>) -> 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<dyn AgentSessionConfigOptions>,
+    config_id: acp::SessionConfigId,
+    picker_handle: PopoverMenuHandle<Picker<ConfigOptionPickerDelegate>>,
+    picker: Entity<Picker<ConfigOptionPickerDelegate>>,
+    setting_value: bool,
+}
+
+impl ConfigOptionSelector {
+    pub fn new(
+        config_options: Rc<dyn AgentSessionConfigOptions>,
+        config_id: acp::SessionConfigId,
+        agent_server: Rc<dyn AgentServer>,
+        fs: Arc<dyn Fs>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<acp::SessionConfigOption> {
+        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<Self>) -> 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<Self>) -> 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<SharedString> = 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<String>,
+    group: Option<String>,
+}
+
+struct ConfigOptionPickerDelegate {
+    config_options: Rc<dyn AgentSessionConfigOptions>,
+    config_id: acp::SessionConfigId,
+    agent_server: Rc<dyn AgentServer>,
+    fs: Arc<dyn Fs>,
+    filtered_entries: Vec<ConfigOptionPickerEntry>,
+    all_options: Vec<ConfigOptionValue>,
+    selected_index: usize,
+    selected_description: Option<(usize, SharedString, bool)>,
+    favorites: HashSet<acp::SessionConfigValueId>,
+    _settings_subscription: Subscription,
+}
+
+impl ConfigOptionPickerDelegate {
+    fn new(
+        config_options: Rc<dyn AgentSessionConfigOptions>,
+        config_id: acp::SessionConfigId,
+        agent_server: Rc<dyn AgentServer>,
+        fs: Arc<dyn Fs>,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> 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::<SettingsStore>(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<acp::SessionConfigValueId> {
+        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<Picker<Self>>) {
+        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<Picker<Self>>,
+    ) -> 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<str> {
+        "Select an option…".into()
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> 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<Picker<Self>>) {
+        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<Picker<Self>>) {
+        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<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        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<Picker<Self>>,
+    ) -> Option<ui::DocumentationAside> {
+        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<dyn AgentSessionConfigOptions>,
+    config_id: &acp::SessionConfigId,
+) -> Vec<ConfigOptionValue> {
+    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<dyn AgentSessionConfigOptions>,
+    config_id: &acp::SessionConfigId,
+) -> Option<acp::SessionConfigValueId> {
+    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<acp::SessionConfigValueId>,
+) -> Vec<ConfigOptionPickerEntry> {
+    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<String> = 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<ConfigOptionValue>,
+    query: &str,
+    executor: BackgroundExecutor,
+) -> Vec<ConfigOptionValue> {
+    let candidates = options
+        .iter()
+        .enumerate()
+        .map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name))
+        .collect::<Vec<_>>();
+
+    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<String> {
+    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,
+    }
+}

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<MessageEditor>,
     focus_handle: FocusHandle,
     model_selector: Option<Entity<AcpModelSelectorPopover>>,
+    config_options_view: Option<Entity<ConfigOptionsView>>,
     profile_selector: Option<Entity<ProfileSelector>>,
     notifications: Vec<WindowHandle<AgentNotification>>,
     notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
@@ -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)),
                     ),
             )

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(),
                             },
                         );
                 }

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(_) => {}
         }
     }
 

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";
+}

crates/project/src/agent_server_store.rs πŸ”—

@@ -1869,6 +1869,8 @@ pub struct BuiltinAgentServerSettings {
     pub default_mode: Option<String>,
     pub default_model: Option<String>,
     pub favorite_models: Vec<String>,
+    pub default_config_options: HashMap<String, String>,
+    pub favorite_config_option_values: HashMap<String, Vec<String>>,
 }
 
 impl BuiltinAgentServerSettings {
@@ -1893,6 +1895,8 @@ impl From<settings::BuiltinAgentServerSettings> 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<String>,
+        /// Default values for session config options.
+        ///
+        /// This is a map from config option ID to value ID.
+        ///
+        /// Default: {}
+        default_config_options: HashMap<String, String>,
+        /// 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<String, Vec<String>>,
     },
     Extension {
         /// The default mode to use for this agent.
@@ -1946,6 +1962,18 @@ pub enum CustomAgentServerSettings {
         ///
         /// Default: []
         favorite_models: Vec<String>,
+        /// Default values for session config options.
+        ///
+        /// This is a map from config option ID to value ID.
+        ///
+        /// Default: {}
+        default_config_options: HashMap<String, String>,
+        /// 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<String, Vec<String>>,
     },
 }
 
@@ -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<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
@@ -1995,6 +2051,8 @@ impl From<settings::CustomAgentServerSettings> 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<settings::CustomAgentServerSettings> 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();

crates/settings/src/settings_content/agent.rs πŸ”—

@@ -370,6 +370,20 @@ pub struct BuiltinAgentServerSettings {
     /// Default: []
     #[serde(default)]
     pub favorite_models: Vec<String>,
+    /// 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<String, String>,
+    /// 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<String, Vec<String>>,
 }
 
 #[with_fallible_options]
@@ -401,6 +415,20 @@ pub enum CustomAgentServerSettings {
         /// Default: []
         #[serde(default)]
         favorite_models: Vec<String>,
+        /// Default values for session config options.
+        ///
+        /// This is a map from config option ID to value ID.
+        ///
+        /// Default: {}
+        #[serde(default)]
+        default_config_options: HashMap<String, String>,
+        /// 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<String, Vec<String>>,
     },
     Extension {
         /// The default mode to use for this agent.
@@ -422,5 +450,19 @@ pub enum CustomAgentServerSettings {
         /// Default: []
         #[serde(default)]
         favorite_models: Vec<String>,
+        /// Default values for session config options.
+        ///
+        /// This is a map from config option ID to value ID.
+        ///
+        /// Default: {}
+        #[serde(default)]
+        default_config_options: HashMap<String, String>,
+        /// 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<String, Vec<String>>,
     },
 }