acp: Support session modes (e.g. CC plan mode) (#37632)

Agus Zubiaga , Bennet Bo Fenner , Richard Feldman , and Danilo Leal created

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 <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

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

Detailed changes

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

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"

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"
     }
   },
   {

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"
     }
   },
   {

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"
     }
   },
   {

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,

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

crates/acp_thread/src/acp_thread.rs 🔗

@@ -805,6 +805,7 @@ pub enum AcpThreadEvent {
     PromptCapabilitiesUpdated,
     Refusal,
     AvailableCommandsUpdated(Vec<acp::AvailableCommand>),
+    ModeUpdated(acp::SessionModeId),
 }
 
 impl EventEmitter<AcpThreadEvent> 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<acp::PermissionOption>,
+        respect_always_allow_setting: bool,
         cx: &mut Context<Self>,
     ) -> Result<BoxFuture<'static, acp::RequestPermissionOutcome>> {
         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| {

crates/acp_thread/src/connection.rs 🔗

@@ -75,6 +75,15 @@ pub trait AgentConnection {
     fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
         None
     }
+
+    fn session_modes(
+        &self,
+        _session_id: &acp::SessionId,
+        _cx: &App,
+    ) -> Option<Rc<dyn AgentSessionModes>> {
+        None
+    }
+
     fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
 }
 
@@ -109,6 +118,14 @@ pub trait AgentTelemetry {
     ) -> Task<Result<serde_json::Value>>;
 }
 
+pub trait AgentSessionModes {
+    fn current_mode(&self) -> acp::SessionModeId;
+
+    fn all_modes(&self) -> Vec<acp::SessionMode>;
+
+    fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task<Result<()>>;
+}
+
 #[derive(Debug)]
 pub struct AuthRequired {
     pub description: Option<String>,
@@ -397,6 +414,7 @@ mod test_support {
                                     thread.request_tool_call_authorization(
                                         tool_call.clone().into(),
                                         options.clone(),
+                                        false,
                                         cx,
                                     )
                                 })??

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 } =

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<RefCell<HashMap<acp::SessionId, AcpSession>>>,
     auth_methods: Vec<acp::AuthMethod>,
     agent_capabilities: acp::AgentCapabilities,
+    default_mode: Option<acp::SessionModeId>,
     root_dir: PathBuf,
     _io_task: Task<Result<()>>,
     _wait_task: Task<Result<()>>,
@@ -39,16 +41,26 @@ pub struct AcpConnection {
 pub struct AcpSession {
     thread: WeakEntity<AcpThread>,
     suppress_abort_err: bool,
+    session_modes: Option<Rc<RefCell<acp::SessionModeState>>>,
 }
 
 pub async fn connect(
     server_name: SharedString,
     command: AgentServerCommand,
     root_dir: &Path,
+    default_mode: Option<acp::SessionModeId>,
     is_remote: bool,
     cx: &mut AsyncApp,
 ) -> Result<Rc<dyn AgentConnection>> {
-    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<acp::SessionModeId>,
         is_remote: bool,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
@@ -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<Result<Entity<AcpThread>>> {
+        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::<Vec<_>>()
+                            .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<Rc<dyn acp_thread::AgentSessionModes>> {
+        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<Self>) -> Rc<dyn Any> {
         self
     }
 }
 
+struct AcpSessionModes {
+    session_id: acp::SessionId,
+    connection: Rc<acp::ClientSideConnection>,
+    state: Rc<RefCell<acp::SessionModeState>>,
+}
+
+impl acp_thread::AgentSessionModes for AcpSessionModes {
+    fn current_mode(&self) -> acp::SessionModeId {
+        self.state.borrow().current_mode_id.clone()
+    }
+
+    fn all_modes(&self) -> Vec<acp::SessionMode> {
+        self.state.borrow().available_modes.clone()
+    }
+
+    fn set_mode(&self, mode_id: acp::SessionModeId, cx: &mut App) -> Task<Result<()>> {
+        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<RefCell<HashMap<acp::SessionId, AcpSession>>>,
     cx: AsyncApp,
@@ -361,13 +491,27 @@ impl acp::Client for ClientDelegate {
         &self,
         arguments: acp::RequestPermissionRequest,
     ) -> Result<acp::RequestPermissionResponse, acp::Error> {
+        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(&notification.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(&notification.session_id)
+            .context("Failed to get session")?;
+
+        if let acp::SessionUpdate::CurrentModeUpdate { current_mode_id } = &notification.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(())
     }

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<agent_client_protocol::SessionModeId> {
+        None
+    }
+    fn set_default_mode(
+        &self,
+        _mode_id: Option<agent_client_protocol::SessionModeId>,
+        _fs: Arc<dyn Fs>,
+        _cx: &mut App,
+    ) {
+    }
 
     fn connect(
         &self,

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<acp::SessionModeId> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(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<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
+        update_settings_file::<AllAgentServersSettings>(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))
         })
     }

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<acp::SessionModeId> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings
+                .get::<AllAgentServersSettings>(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<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
+        let name = self.name();
+        update_settings_file::<AllAgentServersSettings>(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))
         })
     }

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<FakeFs> {
         #[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(),

crates/agent_servers/src/gemini.rs 🔗

@@ -40,6 +40,7 @@ impl AgentServer for Gemini {
         let proxy_url = cx.read_global(|settings: &SettingsStore, _| {
             settings.get::<ProxySettings>(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))
         })
     }

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<BuiltinAgentServerSettings>,
+    pub claude: Option<BuiltinAgentServerSettings>,
+
+    /// Custom agent servers configured by the user
+    #[serde(flatten)]
+    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
+}
+
+#[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<PathBuf>,
+    /// If a binary is specified in `command`, it will be passed these arguments.
+    pub args: Option<Vec<String>>,
+    /// If a binary is specified in `command`, it will be passed these environment variables.
+    pub env: Option<HashMap<String, String>>,
+    /// 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<bool>,
+    /// 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<acp::SessionModeId>,
+}
+
+impl BuiltinAgentServerSettings {
+    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
+        self.path.map(|path| AgentServerCommand {
+            path,
+            args: self.args.unwrap_or_default(),
+            env: self.env,
+        })
+    }
+}
+
+impl From<AgentServerCommand> 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<acp::SessionModeId>,
+}
+
+impl settings::Settings for AllAgentServersSettings {
+    type FileContent = Self;
+
+    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
+        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) {}
+}

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<bool>,
     /// Where to show a popup notification when the agent is waiting for user input.

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::*;

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<dyn AgentSessionModes>,
+    agent_server: Rc<dyn AgentServer>,
+    menu_handle: PopoverMenuHandle<ContextMenu>,
+    focus_handle: FocusHandle,
+    fs: Arc<dyn Fs>,
+    setting_mode: bool,
+}
+
+impl ModeSelector {
+    pub fn new(
+        session_modes: Rc<dyn AgentSessionModes>,
+        agent_server: Rc<dyn AgentServer>,
+        fs: Arc<dyn Fs>,
+        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<ContextMenu> {
+        self.menu_handle.clone()
+    }
+
+    pub fn cycle_mode(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        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<Self>) {
+        let task = self.connection.set_mode(mode, cx);
+        self.setting_mode = true;
+        cx.notify();
+
+        cx.spawn(async move |this: WeakEntity<ModeSelector>, 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<Self>,
+    ) -> Entity<ContextMenu> {
+        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 == &current_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<Self>) -> 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)))
+            })
+    }
+}

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<AcpThread>,
         title_editor: Option<Entity<Editor>>,
+        mode_selector: Option<Entity<ModeSelector>>,
         _subscriptions: Vec<Subscription>,
     },
     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<ModeSelector>> {
+        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<Markdown>,
         tool_call_id: acp::ToolCallId,
+        context_ix: usize,
         card_layout: bool,
         window: &Window,
         cx: &Context<Self>,
@@ -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<Self>) -> 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)),
                     ),

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

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,

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.

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<BuiltinAgentServerSettings>,
-    pub claude: Option<CustomAgentServerSettings>,
+    pub claude: Option<BuiltinAgentServerSettings>,
 
     /// Custom agent servers configured by the user
     #[serde(flatten)]
@@ -1027,6 +1030,12 @@ pub struct BuiltinAgentServerSettings {
     ///
     /// Default: true
     pub ignore_system_version: Option<bool>,
+    /// The default mode to use for this agent.
+    ///
+    /// Note: Not only all agents support modes.
+    ///
+    /// Default: None
+    pub default_mode: Option<String>,
 }
 
 impl BuiltinAgentServerSettings {
@@ -1054,6 +1063,12 @@ impl From<AgentServerCommand> 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<String>,
 }
 
 impl settings::Settings for AllAgentServersSettings {