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

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

Release Notes:

- N/A
This commit is contained in:
Ben Brandt
2025-12-27 23:10:37 +01:00
committed by GitHub
parent 1e4a970ae2
commit bc24ffe863
19 changed files with 1615 additions and 63 deletions

1
Cargo.lock generated
View File

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

View File

@@ -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(())

View File

@@ -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>,

View File

@@ -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

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

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

View File

@@ -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,
)

View File

@@ -1,3 +1,4 @@
mod config_options;
mod entry_view_state;
mod message_editor;
mod mode_selector;

View File

@@ -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,
}
}

View File

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

View File

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

View File

@@ -1363,7 +1363,8 @@ impl AgentDiff {
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::ModeUpdated(_) => {}
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_) => {}
}
}

View File

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

View File

@@ -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();

View File

@@ -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>>,
},
}