From 5e397e85b10fc55fa979236290b46f2a0bd29ec4 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 9 Sep 2025 13:28:02 -0300 Subject: [PATCH] acp: Support session modes (e.g. CC plan mode) (#37632) Adds support for [ACP session modes](https://github.com/zed-industries/agent-client-protocol/pull/67) enabling plan and other permission modes in CC: https://github.com/user-attachments/assets/dea18d82-4da6-465e-983b-02b77c6dcf15 Release Notes: - Claude Code: Add support for plan mode, and all other permission modes --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Richard Feldman Co-authored-by: Danilo Leal --- Cargo.lock | 4 +- Cargo.toml | 2 +- assets/keymaps/default-linux.json | 11 +- assets/keymaps/default-macos.json | 12 +- assets/keymaps/default-windows.json | 9 +- assets/settings/default.json | 3 + crates/acp_thread/Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 7 +- crates/acp_thread/src/connection.rs | 18 ++ crates/agent2/src/agent.rs | 4 +- crates/agent_servers/src/acp.rs | 180 +++++++++++++- crates/agent_servers/src/agent_servers.rs | 13 +- crates/agent_servers/src/claude.rs | 36 ++- crates/agent_servers/src/custom.rs | 42 +++- crates/agent_servers/src/e2e_tests.rs | 14 +- crates/agent_servers/src/gemini.rs | 12 +- crates/agent_servers/src/settings.rs | 125 ++++++++++ crates/agent_settings/src/agent_settings.rs | 4 + crates/agent_ui/src/acp.rs | 2 + crates/agent_ui/src/acp/mode_selector.rs | 230 +++++++++++++++++ crates/agent_ui/src/acp/thread_view.rs | 261 +++++++++++++------- crates/agent_ui/src/agent_configuration.rs | 1 + crates/agent_ui/src/agent_diff.rs | 3 +- crates/agent_ui/src/agent_panel.rs | 11 +- crates/agent_ui/src/agent_ui.rs | 4 +- crates/project/src/agent_server_store.rs | 19 +- 26 files changed, 886 insertions(+), 143 deletions(-) create mode 100644 crates/agent_servers/src/settings.rs create mode 100644 crates/agent_ui/src/acp/mode_selector.rs diff --git a/Cargo.lock b/Cargo.lock index 3c25fc5b008332d3c63ceb52b36d7b4b44a132cb..1c8f49103f476c5f04887ccf2dbd9bd6b560a6f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,9 +196,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.2.0-alpha.6" +version = "0.2.0-alpha.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d02292efd75080932b6466471d428c70e2ac06908ae24792fc7c36ecbaf67ca" +checksum = "08539e8d6b2ccca6cd00afdd42211698f7677adef09108a09414c11f1f45fdaf" dependencies = [ "anyhow", "async-broadcast", diff --git a/Cargo.toml b/Cargo.toml index 0f13835f0c87b94c5acf335a63b0067d50fff156..b5fd14087bcdd4675c630d5bb1137b8e8c3743c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -433,7 +433,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # -agent-client-protocol = { version = "0.2.0-alpha.6", features = ["unstable"]} +agent-client-protocol = { version = "0.2.0-alpha.8", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ac44b3f1ae55feb11b0027efea14c6afed8cb62a..07539e9cbff85eafda0e69228ff33d8631f8f522 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -328,6 +328,12 @@ "enter": "agent::AcceptSuggestedContext" } }, + { + "context": "AcpThread > ModeSelector", + "bindings": { + "ctrl-enter": "menu::Confirm" + } + }, { "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, @@ -335,7 +341,7 @@ "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" + "ctrl-shift-n": "agent::RejectAll", } }, { @@ -345,7 +351,8 @@ "ctrl-enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" + "ctrl-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 337915527ca22f04afc8450cf6a366d1f2995551..8cf1217fb06ef6e13b814b0c00c489ba39e3ca38 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -378,6 +378,12 @@ "ctrl--": "pane::GoBack" } }, + { + "context": "AcpThread > ModeSelector", + "bindings": { + "cmd-enter": "menu::Confirm" + } + }, { "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, @@ -385,7 +391,8 @@ "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" + "cmd-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" } }, { @@ -395,7 +402,8 @@ "cmd-enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" + "cmd-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" } }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index de0d97b52e2b0fe9bac931cb46debc812a56a70b..b10ac964339efed3a3237af08e65f5d519dde8ce 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -336,6 +336,12 @@ "enter": "agent::AcceptSuggestedContext" } }, + { + "context": "AcpThread > ModeSelector", + "bindings": { + "ctrl-enter": "menu::Confirm" + } + }, { "context": "AcpThread > Editor", "use_key_equivalents": true, @@ -343,7 +349,8 @@ "enter": "agent::Chat", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" + "ctrl-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" } }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index 2f04687925374992bbea42e13218e52635ec23a5..802e6fd5858b7bfdad33d84ff5e5a4249dfac7f6 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -828,6 +828,9 @@ // } ], // When enabled, the agent can run potentially destructive actions without asking for your confirmation. + // + // Note: This setting has no effect on external agents that support permission modes, such as Claude Code. + // You can set `agent_servers.claude.default_mode` to `bypassPermissions` to skip all permission requests. "always_allow_tool_actions": false, // When enabled, the agent will stream edits. "stream_edits": false, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 8d7bea8659c3f22d053e47d4b050bc4072e521ba..a0bbda848f9ec761aebdf66b644a8b2926685122 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -18,8 +18,8 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true agent-client-protocol.workspace = true -anyhow.workspace = true agent_settings.workspace = true +anyhow.workspace = true buffer_diff.workspace = true collections.workspace = true editor.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index f36ed6e7a04876dcb057f87889c6c224934681bc..085ddfc50de3416142d79eae66db207aa261e536 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -805,6 +805,7 @@ pub enum AcpThreadEvent { PromptCapabilitiesUpdated, Refusal, AvailableCommandsUpdated(Vec), + ModeUpdated(acp::SessionModeId), } impl EventEmitter for AcpThread {} @@ -1007,6 +1008,9 @@ impl AcpThread { acp::SessionUpdate::AvailableCommandsUpdate { available_commands } => { cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)) } + acp::SessionUpdate::CurrentModeUpdate { current_mode_id } => { + cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)) + } } Ok(()) } @@ -1303,11 +1307,12 @@ impl AcpThread { &mut self, tool_call: acp::ToolCallUpdate, options: Vec, + respect_always_allow_setting: bool, cx: &mut Context, ) -> Result> { let (tx, rx) = oneshot::channel(); - if AgentSettings::get_global(cx).always_allow_tool_actions { + if respect_always_allow_setting && AgentSettings::get_global(cx).always_allow_tool_actions { // Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions, // some tools would (incorrectly) continue to auto-accept. if let Some(allow_once_option) = options.iter().find_map(|option| { diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 1c465a4cdd466e34dcb8fc31ed910f84a4469582..dfb1e3763d504e65bfbef636fb8c592643ce92c9 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -75,6 +75,15 @@ pub trait AgentConnection { fn telemetry(&self) -> Option> { None } + + fn session_modes( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + None + } + fn into_any(self: Rc) -> Rc; } @@ -109,6 +118,14 @@ pub trait AgentTelemetry { ) -> Task>; } +pub trait AgentSessionModes { + fn current_mode(&self) -> acp::SessionModeId; + + fn all_modes(&self) -> Vec; + + fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task>; +} + #[derive(Debug)] pub struct AuthRequired { pub description: Option, @@ -397,6 +414,7 @@ mod test_support { thread.request_tool_call_authorization( tool_call.clone().into(), options.clone(), + false, cx, ) })?? diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index e96b4c0cfa32be910a7a77e58a1911deb7e5357a..6e0df0cffd8e83c446c4acd1fde74c0f8e4b5b8c 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -771,7 +771,9 @@ impl NativeAgentConnection { response, }) => { let outcome_task = acp_thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization(tool_call, options, cx) + thread.request_tool_call_authorization( + tool_call, options, true, cx, + ) })??; cx.background_spawn(async move { if let acp::RequestPermissionOutcome::Selected { option_id } = diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 191bae066ce255ca0e88da215c7513703f7ace0b..0664b437505155beada380484bba4409b0a87695 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,6 +9,7 @@ use futures::io::BufReader; use project::Project; use project::agent_server_store::AgentServerCommand; use serde::Deserialize; +use util::ResultExt; use std::path::PathBuf; use std::{any::Any, cell::RefCell}; @@ -30,6 +31,7 @@ pub struct AcpConnection { sessions: Rc>>, auth_methods: Vec, agent_capabilities: acp::AgentCapabilities, + default_mode: Option, root_dir: PathBuf, _io_task: Task>, _wait_task: Task>, @@ -39,16 +41,26 @@ pub struct AcpConnection { pub struct AcpSession { thread: WeakEntity, suppress_abort_err: bool, + session_modes: Option>>, } pub async fn connect( server_name: SharedString, command: AgentServerCommand, root_dir: &Path, + default_mode: Option, is_remote: bool, cx: &mut AsyncApp, ) -> Result> { - let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, is_remote, cx).await?; + let conn = AcpConnection::stdio( + server_name, + command.clone(), + root_dir, + default_mode, + is_remote, + cx, + ) + .await?; Ok(Rc::new(conn) as _) } @@ -59,6 +71,7 @@ impl AcpConnection { server_name: SharedString, command: AgentServerCommand, root_dir: &Path, + default_mode: Option, is_remote: bool, cx: &mut AsyncApp, ) -> Result { @@ -157,6 +170,7 @@ impl AcpConnection { server_name, sessions, agent_capabilities: response.agent_capabilities, + default_mode, _io_task: io_task, _wait_task: wait_task, _stderr_task: stderr_task, @@ -179,8 +193,10 @@ impl AgentConnection for AcpConnection { cwd: &Path, cx: &mut App, ) -> Task>> { + let name = self.server_name.clone(); let conn = self.connection.clone(); let sessions = self.sessions.clone(); + let default_mode = self.default_mode.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() { @@ -190,7 +206,7 @@ impl AgentConnection for AcpConnection { .filter_map(|id| { let configuration = context_server_store.configuration_for_server(id)?; let command = configuration.command(); - Some(acp::McpServer { + Some(acp::McpServer::Stdio { name: id.0.to_string(), command: command.path.clone(), args: command.args.clone(), @@ -232,6 +248,53 @@ impl AgentConnection for AcpConnection { } })?; + let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes))); + + if let Some(default_mode) = default_mode { + if let Some(modes) = modes.as_ref() { + let mut modes_ref = modes.borrow_mut(); + let has_mode = modes_ref.available_modes.iter().any(|mode| mode.id == default_mode); + + if has_mode { + let initial_mode_id = modes_ref.current_mode_id.clone(); + + cx.spawn({ + let default_mode = default_mode.clone(); + let session_id = response.session_id.clone(); + let modes = modes.clone(); + async move |_| { + let result = conn.set_session_mode(acp::SetSessionModeRequest { + session_id, + mode_id: default_mode, + }) + .await.log_err(); + + if result.is_none() { + modes.borrow_mut().current_mode_id = initial_mode_id; + } + } + }).detach(); + + modes_ref.current_mode_id = default_mode; + } else { + let available_modes = modes_ref + .available_modes + .iter() + .map(|mode| format!("- `{}`: {}", mode.id, mode.name)) + .collect::>() + .join("\n"); + + log::warn!( + "`{default_mode}` is not valid {name} mode. Available options:\n{available_modes}", + ); + } + } else { + log::warn!( + "`{name}` does not support modes, but `default_mode` was set in settings.", + ); + } + } + let session_id = response.session_id; let action_log = cx.new(|_| ActionLog::new(project.clone()))?; let thread = cx.new(|cx| { @@ -250,6 +313,7 @@ impl AgentConnection for AcpConnection { let session = AcpSession { thread: thread.downgrade(), suppress_abort_err: false, + session_modes: modes }; sessions.borrow_mut().insert(session_id, session); @@ -346,11 +410,77 @@ impl AgentConnection for AcpConnection { .detach(); } + fn session_modes( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + let sessions = self.sessions.clone(); + let sessions_ref = sessions.borrow(); + let Some(session) = sessions_ref.get(session_id) else { + return None; + }; + + if let Some(modes) = session.session_modes.as_ref() { + Some(Rc::new(AcpSessionModes { + connection: self.connection.clone(), + session_id: session_id.clone(), + state: modes.clone(), + }) as _) + } else { + None + } + } + fn into_any(self: Rc) -> Rc { self } } +struct AcpSessionModes { + session_id: acp::SessionId, + connection: Rc, + state: Rc>, +} + +impl acp_thread::AgentSessionModes for AcpSessionModes { + fn current_mode(&self) -> acp::SessionModeId { + self.state.borrow().current_mode_id.clone() + } + + fn all_modes(&self) -> Vec { + self.state.borrow().available_modes.clone() + } + + fn set_mode(&self, mode_id: acp::SessionModeId, cx: &mut App) -> Task> { + let connection = self.connection.clone(); + let session_id = self.session_id.clone(); + let old_mode_id; + { + let mut state = self.state.borrow_mut(); + old_mode_id = state.current_mode_id.clone(); + state.current_mode_id = mode_id.clone(); + }; + let state = self.state.clone(); + cx.foreground_executor().spawn(async move { + let result = connection + .set_session_mode(acp::SetSessionModeRequest { + session_id, + mode_id, + }) + .await; + + if result.is_err() { + state.borrow_mut().current_mode_id = old_mode_id; + } + + result?; + + Ok(()) + }) + } +} + struct ClientDelegate { sessions: Rc>>, cx: AsyncApp, @@ -361,13 +491,27 @@ impl acp::Client for ClientDelegate { &self, arguments: acp::RequestPermissionRequest, ) -> Result { + let respect_always_allow_setting; + let thread; + { + let sessions_ref = self.sessions.borrow(); + let session = sessions_ref + .get(&arguments.session_id) + .context("Failed to get session")?; + respect_always_allow_setting = session.session_modes.is_none(); + thread = session.thread.clone(); + } + let cx = &mut self.cx.clone(); - let task = self - .session_thread(&arguments.session_id)? - .update(cx, |thread, cx| { - thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx) - })??; + let task = thread.update(cx, |thread, cx| { + thread.request_tool_call_authorization( + arguments.tool_call, + arguments.options, + respect_always_allow_setting, + cx, + ) + })??; let outcome = task.await; @@ -410,10 +554,24 @@ impl acp::Client for ClientDelegate { &self, notification: acp::SessionNotification, ) -> Result<(), acp::Error> { - self.session_thread(¬ification.session_id)? - .update(&mut self.cx.clone(), |thread, cx| { - thread.handle_session_update(notification.update, cx) - })??; + let sessions = self.sessions.borrow(); + let session = sessions + .get(¬ification.session_id) + .context("Failed to get session")?; + + if let acp::SessionUpdate::CurrentModeUpdate { current_mode_id } = ¬ification.update { + if let Some(session_modes) = &session.session_modes { + session_modes.borrow_mut().current_mode_id = current_mode_id.clone(); + } else { + log::error!( + "Got a `CurrentModeUpdate` notification, but they agent didn't specify `modes` during setting setup." + ); + } + } + + session.thread.update(&mut self.cx.clone(), |thread, cx| { + thread.handle_session_update(notification.update, cx) + })??; Ok(()) } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 7f11d8ce93e9b34d5bb03e1c6306b57bad450efc..2c2900cb79328249355704606652c54d08f072e5 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -8,6 +8,7 @@ pub mod e2e_tests; pub use claude::*; pub use custom::*; +use fs::Fs; pub use gemini::*; use project::agent_server_store::AgentServerStore; @@ -15,7 +16,7 @@ use acp_thread::AgentConnection; use anyhow::Result; use gpui::{App, Entity, SharedString, Task}; use project::Project; -use std::{any::Any, path::Path, rc::Rc}; +use std::{any::Any, path::Path, rc::Rc, sync::Arc}; pub use acp::AcpConnection; @@ -50,6 +51,16 @@ pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; fn telemetry_id(&self) -> &'static str; + fn default_mode(&self, _cx: &mut App) -> Option { + None + } + fn set_default_mode( + &self, + _mode_id: Option, + _fs: Arc, + _cx: &mut App, + ) { + } fn connect( &self, diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 0cb2dead3b342803f3becc8d1567b614cc375842..c75c9539abe5fdd03293d98719d4a905b368c4a4 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1,10 +1,14 @@ +use agent_client_protocol as acp; +use fs::Fs; +use settings::{SettingsStore, update_settings_file}; use std::path::Path; use std::rc::Rc; +use std::sync::Arc; use std::{any::Any, path::PathBuf}; use anyhow::{Context as _, Result}; -use gpui::{App, SharedString, Task}; -use project::agent_server_store::CLAUDE_CODE_NAME; +use gpui::{App, AppContext as _, SharedString, Task}; +use project::agent_server_store::{AllAgentServersSettings, CLAUDE_CODE_NAME}; use crate::{AgentServer, AgentServerDelegate}; use acp_thread::AgentConnection; @@ -30,6 +34,22 @@ impl AgentServer for ClaudeCode { ui::IconName::AiClaude } + fn default_mode(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + } + + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { + update_settings_file::(fs, cx, |settings, _| { + settings.claude.get_or_insert_default().default_mode = mode_id.map(|m| m.to_string()) + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -40,6 +60,7 @@ impl AgentServer for ClaudeCode { let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); + let default_mode = self.default_mode(cx); cx.spawn(async move |cx| { let (command, root_dir, login) = store @@ -56,8 +77,15 @@ impl AgentServer for ClaudeCode { )) })?? .await?; - let connection = - crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?; + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, + cx, + ) + .await?; Ok((connection, login)) }) } diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 0fb595ff02cda53ee5ffe4e778417e35d86a8805..f035952a7939201e4b7d990b97e1fc695105d505 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,9 +1,12 @@ use crate::AgentServerDelegate; use acp_thread::AgentConnection; +use agent_client_protocol as acp; use anyhow::{Context as _, Result}; -use gpui::{App, SharedString, Task}; -use project::agent_server_store::ExternalAgentServerName; -use std::{path::Path, rc::Rc}; +use fs::Fs; +use gpui::{App, AppContext as _, SharedString, Task}; +use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName}; +use settings::{SettingsStore, update_settings_file}; +use std::{path::Path, rc::Rc, sync::Arc}; use ui::IconName; /// A generic agent server implementation for custom user-defined agents @@ -30,6 +33,27 @@ impl crate::AgentServer for CustomAgentServer { IconName::Terminal } + fn default_mode(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .cloned() + }); + + settings + .as_ref() + .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + } + + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { + let name = self.name(); + update_settings_file::(fs, cx, move |settings, _| { + settings.custom.get_mut(&name).unwrap().default_mode = mode_id.map(|m| m.to_string()) + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -39,6 +63,7 @@ impl crate::AgentServer for CustomAgentServer { let name = self.name(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string()); let is_remote = delegate.project.read(cx).is_via_remote_server(); + let default_mode = self.default_mode(cx); let store = delegate.store.downgrade(); cx.spawn(async move |cx| { @@ -58,8 +83,15 @@ impl crate::AgentServer for CustomAgentServer { )) })?? .await?; - let connection = - crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?; + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, + cx, + ) + .await?; Ok((connection, login)) }) } diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index eda55a596a2fbfd20024ea9f15157d6d9dd7c2b3..a4af1b6ad5c6048d27764653322c116f655f85fb 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::{AppContext, Entity, TestAppContext}; use indoc::indoc; #[cfg(test)] -use project::agent_server_store::{AgentServerCommand, CustomAgentServerSettings}; +use project::agent_server_store::BuiltinAgentServerSettings; use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings}; use std::{ path::{Path, PathBuf}, @@ -472,12 +472,12 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { #[cfg(test)] AllAgentServersSettings::override_global( AllAgentServersSettings { - claude: Some(CustomAgentServerSettings { - command: AgentServerCommand { - path: "claude-code-acp".into(), - args: vec![], - env: None, - }, + claude: Some(BuiltinAgentServerSettings { + path: Some("claude-code-acp".into()), + args: None, + env: None, + ignore_system_version: None, + default_mode: None, }), gemini: Some(crate::gemini::tests::local_command().into()), custom: collections::HashMap::default(), diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index cf553273db87d7400b56956741540bd061a9c231..01f15557899e1c7826e91d1555320996eccd0f45 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -40,6 +40,7 @@ impl AgentServer for Gemini { let proxy_url = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).proxy.clone() }); + let default_mode = self.default_mode(cx); cx.spawn(async move |cx| { let mut extra_env = HashMap::default(); @@ -69,8 +70,15 @@ impl AgentServer for Gemini { command.args.push(proxy_url_value.clone()); } - let connection = - crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?; + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, + cx, + ) + .await?; Ok((connection, login)) }) } diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a610465be5516664dafd9cd4cb46be96ad89c8b --- /dev/null +++ b/crates/agent_servers/src/settings.rs @@ -0,0 +1,125 @@ +use agent_client_protocol as acp; +use std::path::PathBuf; + +use crate::AgentServerCommand; +use anyhow::Result; +use collections::HashMap; +use gpui::{App, SharedString}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsKey, SettingsSources, SettingsUi}; + +pub fn init(cx: &mut App) { + AllAgentServersSettings::register(cx); +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey)] +#[settings_key(key = "agent_servers")] +pub struct AllAgentServersSettings { + pub gemini: Option, + pub claude: Option, + + /// Custom agent servers configured by the user + #[serde(flatten)] + pub custom: HashMap, +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] +pub struct BuiltinAgentServerSettings { + /// Absolute path to a binary to be used when launching this agent. + /// + /// This can be used to run a specific binary without automatic downloads or searching `$PATH`. + #[serde(rename = "command")] + pub path: Option, + /// If a binary is specified in `command`, it will be passed these arguments. + pub args: Option>, + /// If a binary is specified in `command`, it will be passed these environment variables. + pub env: Option>, + /// Whether to skip searching `$PATH` for an agent server binary when + /// launching this agent. + /// + /// This has no effect if a `command` is specified. Otherwise, when this is + /// `false`, Zed will search `$PATH` for an agent server binary and, if one + /// is found, use it for threads with this agent. If no agent binary is + /// found on `$PATH`, Zed will automatically install and use its own binary. + /// When this is `true`, Zed will not search `$PATH`, and will always use + /// its own binary. + /// + /// Default: true + pub ignore_system_version: Option, + /// The default mode for new threads. + /// + /// Note: Not all agents support modes. + /// + /// Default: None + #[serde(skip_serializing_if = "Option::is_none")] + pub default_mode: Option, +} + +impl BuiltinAgentServerSettings { + pub(crate) fn custom_command(self) -> Option { + self.path.map(|path| AgentServerCommand { + path, + args: self.args.unwrap_or_default(), + env: self.env, + }) + } +} + +impl From for BuiltinAgentServerSettings { + fn from(value: AgentServerCommand) -> Self { + BuiltinAgentServerSettings { + path: Some(value.path), + args: Some(value.args), + env: value.env, + ..Default::default() + } + } +} + +#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] +pub struct CustomAgentServerSettings { + #[serde(flatten)] + pub command: AgentServerCommand, + /// The default mode for new threads. + /// + /// Note: Not all agents support modes. + /// + /// Default: None + #[serde(skip_serializing_if = "Option::is_none")] + pub default_mode: Option, +} + +impl settings::Settings for AllAgentServersSettings { + type FileContent = Self; + + fn load(sources: SettingsSources, _: &mut App) -> Result { + let mut settings = AllAgentServersSettings::default(); + + for AllAgentServersSettings { + gemini, + claude, + custom, + } in sources.defaults_and_customizations() + { + if gemini.is_some() { + settings.gemini = gemini.clone(); + } + if claude.is_some() { + settings.claude = claude.clone(); + } + + // Merge custom agents + for (name, config) in custom { + // Skip built-in agent names to avoid conflicts + if name != "gemini" && name != "claude" { + settings.custom.insert(name.clone(), config.clone()); + } + } + } + + Ok(settings) + } + + fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} +} diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 8c4a190e1c3135b5bbfbc90544bb92db7a6bdd22..e850945a40f46f31543fad2631216139706b405a 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -269,6 +269,10 @@ pub struct AgentSettingsContent { /// Whenever a tool action would normally wait for your confirmation /// that you allow it, always choose to allow it. /// + /// This setting has no effect on external agents that support permission modes, such as Claude Code. + /// + /// Set `agent_servers.claude.default_mode` to `bypassPermissions`, to disable all permission requests when using Claude Code. + /// /// Default: false always_allow_tool_actions: Option, /// Where to show a popup notification when the agent is waiting for user input. diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 6f228b91d6ed74c7cb330aecd1c9de07e386bed1..2e15cd424d6313d981ff8c000f5eeb958aec9370 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,11 +1,13 @@ mod completion_provider; mod entry_view_state; mod message_editor; +mod mode_selector; mod model_selector; mod model_selector_popover; mod thread_history; mod thread_view; +pub use mode_selector::ModeSelector; pub use model_selector::AcpModelSelector; pub use model_selector_popover::AcpModelSelectorPopover; pub use thread_history::*; diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs new file mode 100644 index 0000000000000000000000000000000000000000..b68643859efdcd7fcac5e2ca5f652372a58cc577 --- /dev/null +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -0,0 +1,230 @@ +use acp_thread::AgentSessionModes; +use agent_client_protocol as acp; +use agent_servers::AgentServer; +use fs::Fs; +use gpui::{Context, Entity, FocusHandle, WeakEntity, Window, prelude::*}; +use std::{rc::Rc, sync::Arc}; +use ui::{ + Button, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, + prelude::*, +}; + +use crate::{CycleModeSelector, ToggleProfileSelector}; + +pub struct ModeSelector { + connection: Rc, + agent_server: Rc, + menu_handle: PopoverMenuHandle, + focus_handle: FocusHandle, + fs: Arc, + setting_mode: bool, +} + +impl ModeSelector { + pub fn new( + session_modes: Rc, + agent_server: Rc, + fs: Arc, + focus_handle: FocusHandle, + ) -> Self { + Self { + connection: session_modes, + agent_server, + menu_handle: PopoverMenuHandle::default(), + fs, + setting_mode: false, + focus_handle, + } + } + + pub fn menu_handle(&self) -> PopoverMenuHandle { + self.menu_handle.clone() + } + + pub fn cycle_mode(&mut self, _window: &mut Window, cx: &mut Context) { + let all_modes = self.connection.all_modes(); + let current_mode = self.connection.current_mode(); + + let current_index = all_modes + .iter() + .position(|mode| mode.id.0 == current_mode.0) + .unwrap_or(0); + + let next_index = (current_index + 1) % all_modes.len(); + self.set_mode(all_modes[next_index].id.clone(), cx); + } + + pub fn set_mode(&mut self, mode: acp::SessionModeId, cx: &mut Context) { + let task = self.connection.set_mode(mode, cx); + self.setting_mode = true; + cx.notify(); + + cx.spawn(async move |this: WeakEntity, cx| { + if let Err(err) = task.await { + log::error!("Failed to set session mode: {:?}", err); + } + this.update(cx, |this, cx| { + this.setting_mode = false; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn build_context_menu( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let weak_self = cx.weak_entity(); + + ContextMenu::build(window, cx, move |mut menu, _window, cx| { + let all_modes = self.connection.all_modes(); + let current_mode = self.connection.current_mode(); + let default_mode = self.agent_server.default_mode(cx); + + for mode in all_modes { + let is_selected = &mode.id == ¤t_mode; + let is_default = Some(&mode.id) == default_mode.as_ref(); + let entry = ContextMenuEntry::new(mode.name.clone()) + .toggleable(IconPosition::End, is_selected); + + let entry = if let Some(description) = &mode.description { + entry.documentation_aside(ui::DocumentationSide::Left, { + let description = description.clone(); + + move |cx| { + v_flex() + .gap_1() + .child(Label::new(description.clone())) + .child( + h_flex() + .pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .gap_0p5() + .text_sm() + .text_color(Color::Muted.color(cx)) + .child("Hold") + .child(div().pt_0p5().children(ui::render_modifiers( + &gpui::Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(ui::TextSize::Default.rems(cx).into()), + true, + ))) + .child(div().map(|this| { + if is_default { + this.child("to also unset as default") + } else { + this.child("to also set as default") + } + })), + ) + .into_any_element() + } + }) + } else { + entry + }; + + menu.push_item(entry.handler({ + let mode_id = mode.id.clone(); + let weak_self = weak_self.clone(); + move |window, cx| { + weak_self + .update(cx, |this, cx| { + if window.modifiers().secondary() { + this.agent_server.set_default_mode( + if is_default { + None + } else { + Some(mode_id.clone()) + }, + this.fs.clone(), + cx, + ); + } + + this.set_mode(mode_id.clone(), cx); + }) + .ok(); + } + })); + } + + menu.key_context("ModeSelector") + }) + } +} + +impl Render for ModeSelector { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let current_mode_id = self.connection.current_mode(); + let current_mode_name = self + .connection + .all_modes() + .iter() + .find(|mode| mode.id == current_mode_id) + .map(|mode| mode.name.clone()) + .unwrap_or_else(|| "Unknown".into()); + + let this = cx.entity(); + + let trigger_button = Button::new("mode-selector-trigger", current_mode_name) + .label_size(LabelSize::Small) + .style(ButtonStyle::Subtle) + .color(Color::Muted) + .icon(IconName::ChevronDown) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .disabled(self.setting_mode); + + PopoverMenu::new("mode-selector") + .trigger_with_tooltip( + trigger_button, + Tooltip::element({ + let focus_handle = self.focus_handle.clone(); + move |window, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .pb_1() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Modes")) + .children(KeyBinding::for_action_in( + &CycleModeSelector, + &focus_handle, + window, + cx, + )), + ) + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Toggle Mode Menu")) + .children(KeyBinding::for_action_in( + &ToggleProfileSelector, + &focus_handle, + window, + cx, + )), + ) + .into_any() + } + }), + ) + .anchor(gpui::Corner::BottomRight) + .with_handle(self.menu_handle.clone()) + .menu(move |window, cx| { + Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) + }) + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 35fd0f5a1dff447b92affb9d4e1f10b683ce1084..5c21ab81ea3b03b33173310713fa577756aab761 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -54,6 +54,7 @@ use zed_actions::assistant::OpenRulesLibrary; use super::entry_view_state::EntryViewState; use crate::acp::AcpModelSelectorPopover; +use crate::acp::ModeSelector; use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; @@ -64,8 +65,9 @@ use crate::ui::{ AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, }; use crate::{ - AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow, - KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector, + AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, CycleModeSelector, + ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, + ToggleProfileSelector, }; pub const MIN_EDITOR_LINES: usize = 4; @@ -298,6 +300,7 @@ enum ThreadState { Ready { thread: Entity, title_editor: Option>, + mode_selector: Option>, _subscriptions: Vec, }, LoadError(LoadError), @@ -396,6 +399,7 @@ impl AcpThreadView { message_editor, model_selector: None, profile_selector: None, + notifications: Vec::new(), notification_subscriptions: HashMap::default(), list_state: list_state.clone(), @@ -594,6 +598,23 @@ impl AcpThreadView { }) }); + 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 mut subscriptions = vec![ cx.subscribe_in(&thread, window, Self::handle_thread_event), cx.observe(&action_log, |_, _, cx| cx.notify()), @@ -615,9 +636,11 @@ impl AcpThreadView { } else { None }; + this.thread_state = ThreadState::Ready { thread, title_editor, + mode_selector, _subscriptions: subscriptions, }; this.message_editor.focus_handle(cx).focus(window); @@ -770,6 +793,15 @@ impl AcpThreadView { } } + pub fn mode_selector(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(), + ThreadState::Unauthenticated { .. } + | ThreadState::Loading { .. } + | ThreadState::LoadError { .. } => None, + } + } + pub fn title(&self, cx: &App) -> SharedString { match &self.thread_state { ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), @@ -1365,6 +1397,10 @@ impl AcpThreadView { self.available_commands.replace(available_commands); } + AcpThreadEvent::ModeUpdated(_mode) => { + // The connection keeps track of the mode + cx.notify(); + } } cx.notify(); } @@ -2055,6 +2091,7 @@ impl AcpThreadView { acp::ToolKind::Execute => IconName::ToolTerminal, acp::ToolKind::Think => IconName::ToolThink, acp::ToolKind::Fetch => IconName::ToolWeb, + acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, acp::ToolKind::Other => IconName::ToolHammer, }) } @@ -2105,59 +2142,67 @@ impl AcpThreadView { }) }; - let tool_output_display = if is_open { - match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() - .w_full() - .children(tool_call.content.iter().map(|content| { - div() - .child(self.render_tool_call_content( - entry_ix, - content, - tool_call, - use_card_layout, - window, - cx, - )) - .into_any_element() - })) - .child(self.render_permission_buttons( - options, - entry_ix, - tool_call.id.clone(), - cx, - )) - .into_any(), - ToolCallStatus::Pending | ToolCallStatus::InProgress - if is_edit - && tool_call.content.is_empty() - && self.as_native_connection(cx).is_some() => - { - self.render_diff_loading(cx).into_any() - } - ToolCallStatus::Pending - | ToolCallStatus::InProgress - | ToolCallStatus::Completed - | ToolCallStatus::Failed - | ToolCallStatus::Canceled => v_flex() - .w_full() - .children(tool_call.content.iter().map(|content| { - div().child(self.render_tool_call_content( + let tool_output_display = + if is_open { + match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() + .w_full() + .children(tool_call.content.iter().enumerate().map( + |(content_ix, content)| { + div() + .child(self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + window, + cx, + )) + .into_any_element() + }, + )) + .child(self.render_permission_buttons( + tool_call.kind, + options, entry_ix, - content, - tool_call, - use_card_layout, - window, + tool_call.id.clone(), cx, )) - })) - .into_any(), - ToolCallStatus::Rejected => Empty.into_any(), - } - .into() - } else { - None - }; + .into_any(), + ToolCallStatus::Pending | ToolCallStatus::InProgress + if is_edit + && tool_call.content.is_empty() + && self.as_native_connection(cx).is_some() => + { + self.render_diff_loading(cx).into_any() + } + ToolCallStatus::Pending + | ToolCallStatus::InProgress + | ToolCallStatus::Completed + | ToolCallStatus::Failed + | ToolCallStatus::Canceled => v_flex() + .w_full() + .children(tool_call.content.iter().enumerate().map( + |(content_ix, content)| { + div().child(self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + window, + cx, + )) + }, + )) + .into_any(), + ToolCallStatus::Rejected => Empty.into_any(), + } + .into() + } else { + None + }; v_flex() .map(|this| { @@ -2282,6 +2327,7 @@ impl AcpThreadView { &self, entry_ix: usize, content: &ToolCallContent, + context_ix: usize, tool_call: &ToolCall, card_layout: bool, window: &Window, @@ -2295,6 +2341,7 @@ impl AcpThreadView { self.render_markdown_output( markdown.clone(), tool_call.id.clone(), + context_ix, card_layout, window, cx, @@ -2314,6 +2361,7 @@ impl AcpThreadView { &self, markdown: Entity, tool_call_id: acp::ToolCallId, + context_ix: usize, card_layout: bool, window: &Window, cx: &Context, @@ -2330,11 +2378,13 @@ impl AcpThreadView { .border_color(self.tool_card_border_color(cx)) }) .when(card_layout, |this| { - this.p_2() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) + this.px_2().pb_2().when(context_ix > 0, |this| { + this.border_t_1() + .pt_2() + .border_color(self.tool_card_border_color(cx)) + }) }) - .text_sm() + .text_xs() .text_color(cx.theme().colors().text_muted) .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx))) .when(!card_layout, |this| { @@ -2415,6 +2465,7 @@ impl AcpThreadView { fn render_permission_buttons( &self, + kind: acp::ToolKind, options: &[acp::PermissionOption], entry_ix: usize, tool_call_id: acp::ToolCallId, @@ -2422,53 +2473,65 @@ impl AcpThreadView { ) -> Div { h_flex() .py_1() - .pl_2() - .pr_1() + .px_1() .gap_1() .justify_between() .flex_wrap() .border_t_1() .border_color(self.tool_card_border_color(cx)) - .child( + .when(kind != acp::ToolKind::SwitchMode, |this| { + this.pl_2().child( + div().min_w(rems_from_px(145.)).child( + LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small), + ), + ) + }) + .child({ div() - .min_w(rems_from_px(145.)) - .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)), - ) - .child(h_flex().gap_0p5().children(options.iter().map(|option| { - let option_id = SharedString::from(option.id.0.clone()); - Button::new((option_id, entry_ix), option.name.clone()) - .map(|this| match option.kind { - acp::PermissionOptionKind::AllowOnce => { - this.icon(IconName::Check).icon_color(Color::Success) - } - acp::PermissionOptionKind::AllowAlways => { - this.icon(IconName::CheckDouble).icon_color(Color::Success) - } - acp::PermissionOptionKind::RejectOnce => { - this.icon(IconName::Close).icon_color(Color::Error) - } - acp::PermissionOptionKind::RejectAlways => { - this.icon(IconName::Close).icon_color(Color::Error) + .map(|this| { + if kind == acp::ToolKind::SwitchMode { + this.w_full().v_flex() + } else { + this.h_flex() } }) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - let option_id = option.id.clone(); - let option_kind = option.kind; - move |this, _, window, cx| { - this.authorize_tool_call( - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } + .gap_0p5() + .children(options.iter().map(|option| { + let option_id = SharedString::from(option.id.0.clone()); + Button::new((option_id, entry_ix), option.name.clone()) + .map(|this| match option.kind { + acp::PermissionOptionKind::AllowOnce => { + this.icon(IconName::Check).icon_color(Color::Success) + } + acp::PermissionOptionKind::AllowAlways => { + this.icon(IconName::CheckDouble).icon_color(Color::Success) + } + acp::PermissionOptionKind::RejectOnce => { + this.icon(IconName::Close).icon_color(Color::Error) + } + acp::PermissionOptionKind::RejectAlways => { + this.icon(IconName::Close).icon_color(Color::Error) + } + }) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + let option_id = option.id.clone(); + let option_kind = option.kind; + move |this, _, window, cx| { + this.authorize_tool_call( + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })) })) - }))) + }) } fn render_diff_loading(&self, cx: &Context) -> AnyElement { @@ -3729,6 +3792,15 @@ impl AcpThreadView { .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { if let Some(profile_selector) = this.profile_selector.as_ref() { profile_selector.read(cx).menu_handle().toggle(window, cx); + } else if let Some(mode_selector) = this.mode_selector() { + mode_selector.read(cx).menu_handle().toggle(window, cx); + } + })) + .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { + if let Some(mode_selector) = this.mode_selector() { + mode_selector.update(cx, |mode_selector, cx| { + mode_selector.cycle_mode(window, cx); + }); } })) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { @@ -3795,6 +3867,7 @@ 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()) .child(self.render_send_button(cx)), ), diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 91ae3f0005b6cc0175dc68c9349c532c76d780ce..ad6ab13e9f3e742457cd5ee41f493c9718025a94 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1322,6 +1322,7 @@ async fn open_new_agent_servers_entry_in_settings_editor( args: vec![], env: Some(HashMap::default()), }, + default_mode: None, }, ); } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index e3688dccce87ab9fb563aa3129fb94c1390d003f..14a60b30148acea49ed81832287a1a8ef51f65a5 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1529,7 +1529,8 @@ impl AgentDiff { | AcpThreadEvent::ToolAuthorizationRequired | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::AvailableCommandsUpdated(_) - | AcpThreadEvent::Retry(_) => {} + | AcpThreadEvent::Retry(_) + | AcpThreadEvent::ModeUpdated(_) => {} } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 55d48319c3fef685623936255f5f796a7b002f3c..a35ed6be42aa0dbd4afb5c44d0a5209cdc9c0b69 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2717,10 +2717,13 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_name - .clone() - .into(), - command: custom_settings.get(&agent_name.0).map(|settings| settings.command.clone()).unwrap_or(placeholder_command()) + name: agent_name.clone().into(), + command: custom_settings + .get(&agent_name.0) + .map(|settings| { + settings.command.clone() + }) + .unwrap_or(placeholder_command()), }, window, cx, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index b16643854ee213c9d0f4370e422b012c1deebd9d..67330e5ea05349b9a36866a3297e29a1863f7551 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -72,8 +72,10 @@ actions!( ToggleOptionsMenu, /// Deletes the recently opened thread from history. DeleteRecentlyOpenThread, - /// Toggles the profile selector for switching between agent profiles. + /// Toggles the profile or mode selector for switching between agent profiles. ToggleProfileSelector, + /// Cycles through available session modes. + CycleModeSelector, /// Removes all added context from the current conversation. RemoveAllContext, /// Expands the message editor to full size. diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 5f9342c8933d43da9bab6d63bc455ea0496d4712..bdb2297624e4a404cb3c918f07eab15004944f97 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -191,7 +191,10 @@ impl AgentServerStore { fs: fs.clone(), node_runtime: node_runtime.clone(), project_environment: project_environment.clone(), - custom_command: new_settings.claude.clone().map(|settings| settings.command), + custom_command: new_settings + .claude + .clone() + .and_then(|settings| settings.custom_command()), }), ); self.external_agents @@ -997,7 +1000,7 @@ pub const CLAUDE_CODE_NAME: &'static str = "claude"; #[settings_key(key = "agent_servers")] pub struct AllAgentServersSettings { pub gemini: Option, - pub claude: Option, + pub claude: Option, /// Custom agent servers configured by the user #[serde(flatten)] @@ -1027,6 +1030,12 @@ pub struct BuiltinAgentServerSettings { /// /// Default: true pub ignore_system_version: Option, + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + pub default_mode: Option, } impl BuiltinAgentServerSettings { @@ -1054,6 +1063,12 @@ impl From for BuiltinAgentServerSettings { pub struct CustomAgentServerSettings { #[serde(flatten)] pub command: AgentServerCommand, + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + pub default_mode: Option, } impl settings::Settings for AllAgentServersSettings {