Cargo.lock π
@@ -268,6 +268,7 @@ dependencies = [
"client",
"collections",
"env_logger 0.11.8",
+ "feature_flags",
"fs",
"futures 0.3.31",
"gpui",
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
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(-)
@@ -268,6 +268,7 @@ dependencies = [
"client",
"collections",
"env_logger 0.11.8",
+ "feature_flags",
"fs",
"futures 0.3.31",
"gpui",
@@ -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(())
@@ -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>,
@@ -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
@@ -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,
+ ..
+ }) = ¬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();
@@ -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,
@@ -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,
)
@@ -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,
)
@@ -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,
)
@@ -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(),
},
@@ -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,
)
@@ -1,3 +1,4 @@
+mod config_options;
mod entry_view_state;
mod message_editor;
mod mode_selector;
@@ -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,
+ }
+}
@@ -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)),
),
)
@@ -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(),
},
);
}
@@ -1363,7 +1363,8 @@ impl AgentDiff {
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::Retry(_)
- | AcpThreadEvent::ModeUpdated(_) => {}
+ | AcpThreadEvent::ModeUpdated(_)
+ | AcpThreadEvent::ConfigOptionsUpdated(_) => {}
}
}
@@ -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";
+}
@@ -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();
@@ -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>>,
},
}