acp: Support automatic installation of Claude Code (#37120)

Cole Miller created

Release Notes:

- N/A

Change summary

Cargo.lock                                         |    5 
crates/agent_servers/Cargo.toml                    |    5 
crates/agent_servers/src/agent_servers.rs          |   10 
crates/agent_servers/src/claude.rs                 | 1362 ---------------
crates/agent_servers/src/claude/edit_tool.rs       |  178 --
crates/agent_servers/src/claude/mcp_server.rs      |   99 -
crates/agent_servers/src/claude/permission_tool.rs |  158 -
crates/agent_servers/src/claude/read_tool.rs       |   59 
crates/agent_servers/src/claude/tools.rs           |  688 --------
crates/agent_servers/src/claude/write_tool.rs      |   59 
crates/agent_servers/src/custom.rs                 |   20 
crates/agent_servers/src/e2e_tests.rs              |   10 
crates/agent_servers/src/gemini.rs                 |   29 
crates/agent_servers/src/settings.rs               |    2 
14 files changed, 76 insertions(+), 2,608 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -292,14 +292,12 @@ dependencies = [
  "anyhow",
  "client",
  "collections",
- "context_server",
  "env_logger 0.11.8",
  "fs",
  "futures 0.3.31",
  "gpui",
  "gpui_tokio",
  "indoc",
- "itertools 0.14.0",
  "language",
  "language_model",
  "language_models",
@@ -309,7 +307,6 @@ dependencies = [
  "node_runtime",
  "paths",
  "project",
- "rand 0.8.5",
  "reqwest_client",
  "schemars",
  "semver",
@@ -317,12 +314,10 @@ dependencies = [
  "serde_json",
  "settings",
  "smol",
- "strum 0.27.1",
  "tempfile",
  "thiserror 2.0.12",
  "ui",
  "util",
- "uuid",
  "watch",
  "which 6.0.3",
  "workspace-hack",

crates/agent_servers/Cargo.toml 🔗

@@ -25,14 +25,12 @@ agent_settings.workspace = true
 anyhow.workspace = true
 client = { workspace = true, optional = true }
 collections.workspace = true
-context_server.workspace = true
 env_logger = { workspace = true, optional = true }
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true
 gpui_tokio = { workspace = true, optional = true }
 indoc.workspace = true
-itertools.workspace = true
 language.workspace = true
 language_model.workspace = true
 language_models.workspace = true
@@ -40,7 +38,6 @@ log.workspace = true
 node_runtime.workspace = true
 paths.workspace = true
 project.workspace = true
-rand.workspace = true
 reqwest_client = { workspace = true, optional = true }
 schemars.workspace = true
 semver.workspace = true
@@ -48,12 +45,10 @@ serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 smol.workspace = true
-strum.workspace = true
 tempfile.workspace = true
 thiserror.workspace = true
 ui.workspace = true
 util.workspace = true
-uuid.workspace = true
 watch.workspace = true
 which.workspace = true
 workspace-hack.workspace = true

crates/agent_servers/src/agent_servers.rs 🔗

@@ -57,16 +57,10 @@ impl AgentServerDelegate {
         binary_name: SharedString,
         package_name: SharedString,
         entrypoint_path: PathBuf,
-        settings: Option<BuiltinAgentServerSettings>,
+        ignore_system_version: bool,
         minimum_version: Option<Version>,
         cx: &mut App,
     ) -> Task<Result<AgentServerCommand>> {
-        if let Some(settings) = &settings
-            && let Some(command) = settings.clone().custom_command()
-        {
-            return Task::ready(Ok(command));
-        }
-
         let project = self.project;
         let fs = project.read(cx).fs().clone();
         let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
@@ -75,7 +69,7 @@ impl AgentServerDelegate {
         let mut status_tx = self.status_tx;
 
         cx.spawn(async move |cx| {
-            if let Some(settings) = settings && !settings.ignore_system_version.unwrap_or(true) {
+            if !ignore_system_version {
                 if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
                     return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() })
                 }

crates/agent_servers/src/claude.rs 🔗

@@ -1,47 +1,23 @@
-mod edit_tool;
-mod mcp_server;
-mod permission_tool;
-mod read_tool;
-pub mod tools;
-mod write_tool;
-
-use action_log::ActionLog;
-use collections::HashMap;
-use context_server::listener::McpServerTool;
 use language_models::provider::anthropic::AnthropicLanguageModelProvider;
-use project::Project;
 use settings::SettingsStore;
-use smol::process::Child;
 use std::any::Any;
-use std::cell::RefCell;
-use std::fmt::Display;
-use std::path::{Path, PathBuf};
+use std::path::Path;
 use std::rc::Rc;
-use util::command::new_smol_command;
-use uuid::Uuid;
 
-use agent_client_protocol as acp;
-use anyhow::{Context as _, Result, anyhow};
-use futures::channel::oneshot;
-use futures::{AsyncBufReadExt, AsyncWriteExt};
-use futures::{
-    AsyncRead, AsyncWrite, FutureExt, StreamExt,
-    channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
-    io::BufReader,
-    select_biased,
-};
-use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
-use serde::{Deserialize, Serialize};
-use util::{ResultExt, debug_panic};
+use anyhow::Result;
+use gpui::{App, AppContext as _, SharedString, Task};
 
-use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
-use crate::claude::tools::ClaudeTool;
-use crate::{AgentServer, AgentServerCommand, AgentServerDelegate, AllAgentServersSettings};
-use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri};
+use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings};
+use acp_thread::AgentConnection;
 
 #[derive(Clone)]
 pub struct ClaudeCode;
 
+impl ClaudeCode {
+    const BINARY_NAME: &'static str = "claude-code-acp";
+    const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp";
+}
+
 impl AgentServer for ClaudeCode {
     fn telemetry_id(&self) -> &'static str {
         "claude-code"
@@ -57,1301 +33,49 @@ impl AgentServer for ClaudeCode {
 
     fn connect(
         &self,
-        _root_dir: &Path,
-        _delegate: AgentServerDelegate,
-        _cx: &mut App,
+        root_dir: &Path,
+        delegate: AgentServerDelegate,
+        cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
-        let connection = ClaudeAgentConnection {
-            sessions: Default::default(),
-        };
-
-        Task::ready(Ok(Rc::new(connection) as _))
-    }
-
-    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
-        self
-    }
-}
-
-struct ClaudeAgentConnection {
-    sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
-}
+        let root_dir = root_dir.to_path_buf();
+        let server_name = self.name();
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(None).claude.clone()
+        });
 
-impl AgentConnection for ClaudeAgentConnection {
-    fn new_thread(
-        self: Rc<Self>,
-        project: Entity<Project>,
-        cwd: &Path,
-        cx: &mut App,
-    ) -> Task<Result<Entity<AcpThread>>> {
-        let cwd = cwd.to_owned();
         cx.spawn(async move |cx| {
-            let settings = cx.read_global(|settings: &SettingsStore, _| {
-                settings.get::<AllAgentServersSettings>(None).claude.clone()
-            })?;
-
-            let Some(command) = AgentServerCommand::resolve(
-                "claude",
-                &[],
-                Some(&util::paths::home_dir().join(".claude/local/claude")),
-                settings,
-                &project,
-                cx,
-            )
-            .await
-            else {
-                return Err(anyhow!("Failed to find Claude Code binary"));
+            let mut command = if let Some(settings) = settings {
+                settings.command
+            } else {
+                cx.update(|cx| {
+                    delegate.get_or_npm_install_builtin_agent(
+                        Self::BINARY_NAME.into(),
+                        Self::PACKAGE_NAME.into(),
+                        format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
+                        true,
+                        None,
+                        cx,
+                    )
+                })?
+                .await?
             };
 
-            let api_key =
-                cx.update(AnthropicLanguageModelProvider::api_key)?
-                    .await
-                    .map_err(|err| {
-                        if err.is::<language_model::AuthenticateError>() {
-                            anyhow!(AuthRequired::new().with_language_model_provider(
-                                language_model::ANTHROPIC_PROVIDER_ID
-                            ))
-                        } else {
-                            anyhow!(err)
-                        }
-                    })?;
-
-            let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
-            let fs = project.read_with(cx, |project, _cx| project.fs().clone())?;
-            let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), fs, cx).await?;
-
-            let mut mcp_servers = HashMap::default();
-            mcp_servers.insert(
-                mcp_server::SERVER_NAME.to_string(),
-                permission_mcp_server.server_config()?,
-            );
-            let mcp_config = McpConfig { mcp_servers };
-
-            let mcp_config_file = tempfile::NamedTempFile::new()?;
-            let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
-
-            let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
-            mcp_config_file
-                .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
-                .await?;
-            mcp_config_file.flush().await?;
-
-            let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
-            let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
-
-            let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
-
-            log::trace!("Starting session with id: {}", session_id);
-
-            let mut child = spawn_claude(
-                &command,
-                ClaudeSessionMode::Start,
-                session_id.clone(),
-                api_key,
-                &mcp_config_path,
-                &cwd,
-            )?;
-
-            let stdout = child.stdout.take().context("Failed to take stdout")?;
-            let stdin = child.stdin.take().context("Failed to take stdin")?;
-            let stderr = child.stderr.take().context("Failed to take stderr")?;
-
-            let pid = child.id();
-            log::trace!("Spawned (pid: {})", pid);
-
-            cx.background_spawn(async move {
-                let mut stderr = BufReader::new(stderr);
-                let mut line = String::new();
-                while let Ok(n) = stderr.read_line(&mut line).await
-                    && n > 0
-                {
-                    log::warn!("agent stderr: {}", &line);
-                    line.clear();
-                }
-            })
-            .detach();
-
-            cx.background_spawn(async move {
-                let mut outgoing_rx = Some(outgoing_rx);
-
-                ClaudeAgentSession::handle_io(
-                    outgoing_rx.take().unwrap(),
-                    incoming_message_tx.clone(),
-                    stdin,
-                    stdout,
-                )
-                .await?;
-
-                log::trace!("Stopped (pid: {})", pid);
-
-                drop(mcp_config_path);
-                anyhow::Ok(())
-            })
-            .detach();
-
-            let turn_state = Rc::new(RefCell::new(TurnState::None));
-
-            let handler_task = cx.spawn({
-                let turn_state = turn_state.clone();
-                let mut thread_rx = thread_rx.clone();
-                async move |cx| {
-                    while let Some(message) = incoming_message_rx.next().await {
-                        ClaudeAgentSession::handle_message(
-                            thread_rx.clone(),
-                            message,
-                            turn_state.clone(),
-                            cx,
-                        )
-                        .await
-                    }
-
-                    if let Some(status) = child.status().await.log_err()
-                        && let Some(thread) = thread_rx.recv().await.ok()
-                    {
-                        let version = claude_version(command.path.clone(), cx).await.log_err();
-                        let help = claude_help(command.path.clone(), cx).await.log_err();
-                        thread
-                            .update(cx, |thread, cx| {
-                                let error = if let Some(version) = version
-                                    && let Some(help) = help
-                                    && (!help.contains("--input-format")
-                                        || !help.contains("--session-id"))
-                                {
-                                    LoadError::Unsupported {
-                                        command: command.path.to_string_lossy().to_string().into(),
-                                        current_version: version.to_string().into(),
-                                        minimum_version: "1.0.0".into(),
-                                    }
-                                } else {
-                                    LoadError::Exited { status }
-                                };
-                                thread.emit_load_error(error, cx);
-                            })
-                            .ok();
-                    }
-                }
-            });
-
-            let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
-            let thread = cx.new(|cx| {
-                AcpThread::new(
-                    "Claude Code",
-                    self.clone(),
-                    project,
-                    action_log,
-                    session_id.clone(),
-                    watch::Receiver::constant(acp::PromptCapabilities {
-                        image: true,
-                        audio: false,
-                        embedded_context: true,
-                    }),
-                    cx,
-                )
-            })?;
-
-            thread_tx.send(thread.downgrade())?;
-
-            let session = ClaudeAgentSession {
-                outgoing_tx,
-                turn_state,
-                _handler_task: handler_task,
-                _mcp_server: Some(permission_mcp_server),
-            };
-
-            self.sessions.borrow_mut().insert(session_id, session);
+            if let Some(api_key) = cx
+                .update(AnthropicLanguageModelProvider::api_key)?
+                .await
+                .ok()
+            {
+                command
+                    .env
+                    .get_or_insert_default()
+                    .insert("ANTHROPIC_API_KEY".to_owned(), api_key.key);
+            }
 
-            Ok(thread)
+            crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
         })
     }
 
-    fn auth_methods(&self) -> &[acp::AuthMethod] {
-        &[]
-    }
-
-    fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
-        Task::ready(Err(anyhow!("Authentication not supported")))
-    }
-
-    fn prompt(
-        &self,
-        _id: Option<acp_thread::UserMessageId>,
-        params: acp::PromptRequest,
-        cx: &mut App,
-    ) -> Task<Result<acp::PromptResponse>> {
-        let sessions = self.sessions.borrow();
-        let Some(session) = sessions.get(&params.session_id) else {
-            return Task::ready(Err(anyhow!(
-                "Attempted to send message to nonexistent session {}",
-                params.session_id
-            )));
-        };
-
-        let (end_tx, end_rx) = oneshot::channel();
-        session.turn_state.replace(TurnState::InProgress { end_tx });
-
-        let content = acp_content_to_claude(params.prompt);
-
-        if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
-            message: Message {
-                role: Role::User,
-                content: Content::Chunks(content),
-                id: None,
-                model: None,
-                stop_reason: None,
-                stop_sequence: None,
-                usage: None,
-            },
-            session_id: Some(params.session_id.to_string()),
-        }) {
-            return Task::ready(Err(anyhow!(err)));
-        }
-
-        cx.foreground_executor().spawn(async move { end_rx.await? })
-    }
-
-    fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
-        let sessions = self.sessions.borrow();
-        let Some(session) = sessions.get(session_id) else {
-            log::warn!("Attempted to cancel nonexistent session {}", session_id);
-            return;
-        };
-
-        let request_id = new_request_id();
-
-        let turn_state = session.turn_state.take();
-        let TurnState::InProgress { end_tx } = turn_state else {
-            // Already canceled or idle, put it back
-            session.turn_state.replace(turn_state);
-            return;
-        };
-
-        session.turn_state.replace(TurnState::CancelRequested {
-            end_tx,
-            request_id: request_id.clone(),
-        });
-
-        session
-            .outgoing_tx
-            .unbounded_send(SdkMessage::ControlRequest {
-                request_id,
-                request: ControlRequest::Interrupt,
-            })
-            .log_err();
-    }
-
     fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
         self
     }
 }
-
-#[derive(Clone, Copy)]
-enum ClaudeSessionMode {
-    Start,
-    #[expect(dead_code)]
-    Resume,
-}
-
-fn spawn_claude(
-    command: &AgentServerCommand,
-    mode: ClaudeSessionMode,
-    session_id: acp::SessionId,
-    api_key: language_models::provider::anthropic::ApiKey,
-    mcp_config_path: &Path,
-    root_dir: &Path,
-) -> Result<Child> {
-    let child = util::command::new_smol_command(&command.path)
-        .args([
-            "--input-format",
-            "stream-json",
-            "--output-format",
-            "stream-json",
-            "--print",
-            "--verbose",
-            "--mcp-config",
-            mcp_config_path.to_string_lossy().as_ref(),
-            "--permission-prompt-tool",
-            &format!(
-                "mcp__{}__{}",
-                mcp_server::SERVER_NAME,
-                permission_tool::PermissionTool::NAME,
-            ),
-            "--allowedTools",
-            &format!(
-                "mcp__{}__{}",
-                mcp_server::SERVER_NAME,
-                read_tool::ReadTool::NAME
-            ),
-            "--disallowedTools",
-            "Read,Write,Edit,MultiEdit",
-        ])
-        .args(match mode {
-            ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
-            ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
-        })
-        .args(command.args.iter().map(|arg| arg.as_str()))
-        .envs(command.env.iter().flatten())
-        .env("ANTHROPIC_API_KEY", api_key.key)
-        .current_dir(root_dir)
-        .stdin(std::process::Stdio::piped())
-        .stdout(std::process::Stdio::piped())
-        .stderr(std::process::Stdio::piped())
-        .kill_on_drop(true)
-        .spawn()?;
-
-    Ok(child)
-}
-
-fn claude_version(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<semver::Version>> {
-    cx.background_spawn(async move {
-        let output = new_smol_command(path).arg("--version").output().await?;
-        let output = String::from_utf8(output.stdout)?;
-        let version = output
-            .trim()
-            .strip_suffix(" (Claude Code)")
-            .context("parsing Claude version")?;
-        let version = semver::Version::parse(version)?;
-        anyhow::Ok(version)
-    })
-}
-
-fn claude_help(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<String>> {
-    cx.background_spawn(async move {
-        let output = new_smol_command(path).arg("--help").output().await?;
-        let output = String::from_utf8(output.stdout)?;
-        anyhow::Ok(output)
-    })
-}
-
-struct ClaudeAgentSession {
-    outgoing_tx: UnboundedSender<SdkMessage>,
-    turn_state: Rc<RefCell<TurnState>>,
-    _mcp_server: Option<ClaudeZedMcpServer>,
-    _handler_task: Task<()>,
-}
-
-#[derive(Debug, Default)]
-enum TurnState {
-    #[default]
-    None,
-    InProgress {
-        end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
-    },
-    CancelRequested {
-        end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
-        request_id: String,
-    },
-    CancelConfirmed {
-        end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
-    },
-}
-
-impl TurnState {
-    fn is_canceled(&self) -> bool {
-        matches!(self, TurnState::CancelConfirmed { .. })
-    }
-
-    fn end_tx(self) -> Option<oneshot::Sender<Result<acp::PromptResponse>>> {
-        match self {
-            TurnState::None => None,
-            TurnState::InProgress { end_tx, .. } => Some(end_tx),
-            TurnState::CancelRequested { end_tx, .. } => Some(end_tx),
-            TurnState::CancelConfirmed { end_tx } => Some(end_tx),
-        }
-    }
-
-    fn confirm_cancellation(self, id: &str) -> Self {
-        match self {
-            TurnState::CancelRequested { request_id, end_tx } if request_id == id => {
-                TurnState::CancelConfirmed { end_tx }
-            }
-            _ => self,
-        }
-    }
-}
-
-impl ClaudeAgentSession {
-    async fn handle_message(
-        mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-        message: SdkMessage,
-        turn_state: Rc<RefCell<TurnState>>,
-        cx: &mut AsyncApp,
-    ) {
-        match message {
-            // we should only be sending these out, they don't need to be in the thread
-            SdkMessage::ControlRequest { .. } => {}
-            SdkMessage::User {
-                message,
-                session_id: _,
-            } => {
-                let Some(thread) = thread_rx
-                    .recv()
-                    .await
-                    .log_err()
-                    .and_then(|entity| entity.upgrade())
-                else {
-                    log::error!("Received an SDK message but thread is gone");
-                    return;
-                };
-
-                for chunk in message.content.chunks() {
-                    match chunk {
-                        ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
-                            if !turn_state.borrow().is_canceled() {
-                                thread
-                                    .update(cx, |thread, cx| {
-                                        thread.push_user_content_block(None, text.into(), cx)
-                                    })
-                                    .log_err();
-                            }
-                        }
-                        ContentChunk::ToolResult {
-                            content,
-                            tool_use_id,
-                        } => {
-                            let content = content.to_string();
-                            thread
-                                .update(cx, |thread, cx| {
-                                    let id = acp::ToolCallId(tool_use_id.into());
-                                    let set_new_content = !content.is_empty()
-                                        && thread.tool_call(&id).is_none_or(|(_, tool_call)| {
-                                            // preserve rich diff if we have one
-                                            tool_call.diffs().next().is_none()
-                                        });
-
-                                    thread.update_tool_call(
-                                        acp::ToolCallUpdate {
-                                            id,
-                                            fields: acp::ToolCallUpdateFields {
-                                                status: if turn_state.borrow().is_canceled() {
-                                                    // Do not set to completed if turn was canceled
-                                                    None
-                                                } else {
-                                                    Some(acp::ToolCallStatus::Completed)
-                                                },
-                                                content: set_new_content
-                                                    .then(|| vec![content.into()]),
-                                                ..Default::default()
-                                            },
-                                        },
-                                        cx,
-                                    )
-                                })
-                                .log_err();
-                        }
-                        ContentChunk::Thinking { .. }
-                        | ContentChunk::RedactedThinking
-                        | ContentChunk::ToolUse { .. } => {
-                            debug_panic!(
-                                "Should not get {:?} with role: assistant. should we handle this?",
-                                chunk
-                            );
-                        }
-                        ContentChunk::Image { source } => {
-                            if !turn_state.borrow().is_canceled() {
-                                thread
-                                    .update(cx, |thread, cx| {
-                                        thread.push_user_content_block(None, source.into(), cx)
-                                    })
-                                    .log_err();
-                            }
-                        }
-
-                        ContentChunk::Document | ContentChunk::WebSearchToolResult => {
-                            thread
-                                .update(cx, |thread, cx| {
-                                    thread.push_assistant_content_block(
-                                        format!("Unsupported content: {:?}", chunk).into(),
-                                        false,
-                                        cx,
-                                    )
-                                })
-                                .log_err();
-                        }
-                    }
-                }
-            }
-            SdkMessage::Assistant {
-                message,
-                session_id: _,
-            } => {
-                let Some(thread) = thread_rx
-                    .recv()
-                    .await
-                    .log_err()
-                    .and_then(|entity| entity.upgrade())
-                else {
-                    log::error!("Received an SDK message but thread is gone");
-                    return;
-                };
-
-                for chunk in message.content.chunks() {
-                    match chunk {
-                        ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
-                            thread
-                                .update(cx, |thread, cx| {
-                                    thread.push_assistant_content_block(text.into(), false, cx)
-                                })
-                                .log_err();
-                        }
-                        ContentChunk::Thinking { thinking } => {
-                            thread
-                                .update(cx, |thread, cx| {
-                                    thread.push_assistant_content_block(thinking.into(), true, cx)
-                                })
-                                .log_err();
-                        }
-                        ContentChunk::RedactedThinking => {
-                            thread
-                                .update(cx, |thread, cx| {
-                                    thread.push_assistant_content_block(
-                                        "[REDACTED]".into(),
-                                        true,
-                                        cx,
-                                    )
-                                })
-                                .log_err();
-                        }
-                        ContentChunk::ToolUse { id, name, input } => {
-                            let claude_tool = ClaudeTool::infer(&name, input);
-
-                            thread
-                                .update(cx, |thread, cx| {
-                                    if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
-                                        thread.update_plan(
-                                            acp::Plan {
-                                                entries: params
-                                                    .todos
-                                                    .into_iter()
-                                                    .map(Into::into)
-                                                    .collect(),
-                                            },
-                                            cx,
-                                        )
-                                    } else {
-                                        thread.upsert_tool_call(
-                                            claude_tool.as_acp(acp::ToolCallId(id.into())),
-                                            cx,
-                                        )?;
-                                    }
-                                    anyhow::Ok(())
-                                })
-                                .log_err();
-                        }
-                        ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => {
-                            debug_panic!(
-                                "Should not get tool results with role: assistant. should we handle this?"
-                            );
-                        }
-                        ContentChunk::Image { source } => {
-                            thread
-                                .update(cx, |thread, cx| {
-                                    thread.push_assistant_content_block(source.into(), false, cx)
-                                })
-                                .log_err();
-                        }
-                        ContentChunk::Document => {
-                            thread
-                                .update(cx, |thread, cx| {
-                                    thread.push_assistant_content_block(
-                                        format!("Unsupported content: {:?}", chunk).into(),
-                                        false,
-                                        cx,
-                                    )
-                                })
-                                .log_err();
-                        }
-                    }
-                }
-            }
-            SdkMessage::Result {
-                is_error,
-                subtype,
-                result,
-                ..
-            } => {
-                let turn_state = turn_state.take();
-                let was_canceled = turn_state.is_canceled();
-                let Some(end_turn_tx) = turn_state.end_tx() else {
-                    debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn");
-                    return;
-                };
-
-                if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) {
-                    end_turn_tx
-                        .send(Err(anyhow!(
-                            "Error: {}",
-                            result.unwrap_or_else(|| subtype.to_string())
-                        )))
-                        .ok();
-                } else {
-                    let stop_reason = match subtype {
-                        ResultErrorType::Success => acp::StopReason::EndTurn,
-                        ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests,
-                        ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled,
-                    };
-                    end_turn_tx
-                        .send(Ok(acp::PromptResponse { stop_reason }))
-                        .ok();
-                }
-            }
-            SdkMessage::ControlResponse { response } => {
-                if matches!(response.subtype, ResultErrorType::Success) {
-                    let new_state = turn_state.take().confirm_cancellation(&response.request_id);
-                    turn_state.replace(new_state);
-                } else {
-                    log::error!("Control response error: {:?}", response);
-                }
-            }
-            SdkMessage::System { .. } => {}
-        }
-    }
-
-    async fn handle_io(
-        mut outgoing_rx: UnboundedReceiver<SdkMessage>,
-        incoming_tx: UnboundedSender<SdkMessage>,
-        mut outgoing_bytes: impl Unpin + AsyncWrite,
-        incoming_bytes: impl Unpin + AsyncRead,
-    ) -> Result<UnboundedReceiver<SdkMessage>> {
-        let mut output_reader = BufReader::new(incoming_bytes);
-        let mut outgoing_line = Vec::new();
-        let mut incoming_line = String::new();
-        loop {
-            select_biased! {
-                message = outgoing_rx.next() => {
-                    if let Some(message) = message {
-                        outgoing_line.clear();
-                        serde_json::to_writer(&mut outgoing_line, &message)?;
-                        log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
-                        outgoing_line.push(b'\n');
-                        outgoing_bytes.write_all(&outgoing_line).await.ok();
-                    } else {
-                        break;
-                    }
-                }
-                bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
-                    if bytes_read? == 0 {
-                        break
-                    }
-                    log::trace!("recv: {}", &incoming_line);
-                    match serde_json::from_str::<SdkMessage>(&incoming_line) {
-                        Ok(message) => {
-                            incoming_tx.unbounded_send(message).log_err();
-                        }
-                        Err(error) => {
-                            log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
-                        }
-                    }
-                    incoming_line.clear();
-                }
-            }
-        }
-
-        Ok(outgoing_rx)
-    }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct Message {
-    role: Role,
-    content: Content,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    id: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    model: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    stop_reason: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    stop_sequence: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    usage: Option<Usage>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-enum Content {
-    UntaggedText(String),
-    Chunks(Vec<ContentChunk>),
-}
-
-impl Content {
-    pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
-        match self {
-            Self::Chunks(chunks) => chunks.into_iter(),
-            Self::UntaggedText(text) => vec![ContentChunk::Text { text }].into_iter(),
-        }
-    }
-}
-
-impl Display for Content {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Content::UntaggedText(txt) => write!(f, "{}", txt),
-            Content::Chunks(chunks) => {
-                for chunk in chunks {
-                    write!(f, "{}", chunk)?;
-                }
-                Ok(())
-            }
-        }
-    }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum ContentChunk {
-    Text {
-        text: String,
-    },
-    ToolUse {
-        id: String,
-        name: String,
-        input: serde_json::Value,
-    },
-    ToolResult {
-        content: Content,
-        tool_use_id: String,
-    },
-    Thinking {
-        thinking: String,
-    },
-    RedactedThinking,
-    Image {
-        source: ImageSource,
-    },
-    // TODO
-    Document,
-    WebSearchToolResult,
-    #[serde(untagged)]
-    UntaggedText(String),
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum ImageSource {
-    Base64 { data: String, media_type: String },
-    Url { url: String },
-}
-
-impl Into<acp::ContentBlock> for ImageSource {
-    fn into(self) -> acp::ContentBlock {
-        match self {
-            ImageSource::Base64 { data, media_type } => {
-                acp::ContentBlock::Image(acp::ImageContent {
-                    annotations: None,
-                    data,
-                    mime_type: media_type,
-                    uri: None,
-                })
-            }
-            ImageSource::Url { url } => acp::ContentBlock::Image(acp::ImageContent {
-                annotations: None,
-                data: "".to_string(),
-                mime_type: "".to_string(),
-                uri: Some(url),
-            }),
-        }
-    }
-}
-
-impl Display for ContentChunk {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            ContentChunk::Text { text } => write!(f, "{}", text),
-            ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking),
-            ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"),
-            ContentChunk::UntaggedText(text) => write!(f, "{}", text),
-            ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
-            ContentChunk::Image { .. }
-            | ContentChunk::Document
-            | ContentChunk::ToolUse { .. }
-            | ContentChunk::WebSearchToolResult => {
-                write!(f, "\n{:?}\n", &self)
-            }
-        }
-    }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct Usage {
-    input_tokens: u32,
-    cache_creation_input_tokens: u32,
-    cache_read_input_tokens: u32,
-    output_tokens: u32,
-    service_tier: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum Role {
-    System,
-    Assistant,
-    User,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct MessageParam {
-    role: Role,
-    content: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum SdkMessage {
-    // An assistant message
-    Assistant {
-        message: Message, // from Anthropic SDK
-        #[serde(skip_serializing_if = "Option::is_none")]
-        session_id: Option<String>,
-    },
-    // A user message
-    User {
-        message: Message, // from Anthropic SDK
-        #[serde(skip_serializing_if = "Option::is_none")]
-        session_id: Option<String>,
-    },
-    // Emitted as the last message in a conversation
-    Result {
-        subtype: ResultErrorType,
-        duration_ms: f64,
-        duration_api_ms: f64,
-        is_error: bool,
-        num_turns: i32,
-        #[serde(skip_serializing_if = "Option::is_none")]
-        result: Option<String>,
-        session_id: String,
-        total_cost_usd: f64,
-    },
-    // Emitted as the first message at the start of a conversation
-    System {
-        cwd: String,
-        session_id: String,
-        tools: Vec<String>,
-        model: String,
-        mcp_servers: Vec<McpServer>,
-        #[serde(rename = "apiKeySource")]
-        api_key_source: String,
-        #[serde(rename = "permissionMode")]
-        permission_mode: PermissionMode,
-    },
-    /// Messages used to control the conversation, outside of chat messages to the model
-    ControlRequest {
-        request_id: String,
-        request: ControlRequest,
-    },
-    /// Response to a control request
-    ControlResponse { response: ControlResponse },
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "subtype", rename_all = "snake_case")]
-enum ControlRequest {
-    /// Cancel the current conversation
-    Interrupt,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct ControlResponse {
-    request_id: String,
-    subtype: ResultErrorType,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
-#[serde(rename_all = "snake_case")]
-enum ResultErrorType {
-    Success,
-    ErrorMaxTurns,
-    ErrorDuringExecution,
-}
-
-impl Display for ResultErrorType {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            ResultErrorType::Success => write!(f, "success"),
-            ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
-            ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
-        }
-    }
-}
-
-fn acp_content_to_claude(prompt: Vec<acp::ContentBlock>) -> Vec<ContentChunk> {
-    let mut content = Vec::with_capacity(prompt.len());
-    let mut context = Vec::with_capacity(prompt.len());
-
-    for chunk in prompt {
-        match chunk {
-            acp::ContentBlock::Text(text_content) => {
-                content.push(ContentChunk::Text {
-                    text: text_content.text,
-                });
-            }
-            acp::ContentBlock::ResourceLink(resource_link) => {
-                match MentionUri::parse(&resource_link.uri) {
-                    Ok(uri) => {
-                        content.push(ContentChunk::Text {
-                            text: format!("{}", uri.as_link()),
-                        });
-                    }
-                    Err(_) => {
-                        content.push(ContentChunk::Text {
-                            text: resource_link.uri,
-                        });
-                    }
-                }
-            }
-            acp::ContentBlock::Resource(resource) => match resource.resource {
-                acp::EmbeddedResourceResource::TextResourceContents(resource) => {
-                    match MentionUri::parse(&resource.uri) {
-                        Ok(uri) => {
-                            content.push(ContentChunk::Text {
-                                text: format!("{}", uri.as_link()),
-                            });
-                        }
-                        Err(_) => {
-                            content.push(ContentChunk::Text {
-                                text: resource.uri.clone(),
-                            });
-                        }
-                    }
-
-                    context.push(ContentChunk::Text {
-                        text: format!(
-                            "\n<context ref=\"{}\">\n{}\n</context>",
-                            resource.uri, resource.text
-                        ),
-                    });
-                }
-                acp::EmbeddedResourceResource::BlobResourceContents(_) => {
-                    // Unsupported by SDK
-                }
-            },
-            acp::ContentBlock::Image(acp::ImageContent {
-                data, mime_type, ..
-            }) => content.push(ContentChunk::Image {
-                source: ImageSource::Base64 {
-                    data,
-                    media_type: mime_type,
-                },
-            }),
-            acp::ContentBlock::Audio(_) => {
-                // Unsupported by SDK
-            }
-        }
-    }
-
-    content.extend(context);
-    content
-}
-
-fn new_request_id() -> String {
-    use rand::Rng;
-    // In the Claude Code TS SDK they just generate a random 12 character string,
-    // `Math.random().toString(36).substring(2, 15)`
-    rand::thread_rng()
-        .sample_iter(&rand::distributions::Alphanumeric)
-        .take(12)
-        .map(char::from)
-        .collect()
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct McpServer {
-    name: String,
-    status: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum PermissionMode {
-    Default,
-    AcceptEdits,
-    BypassPermissions,
-    Plan,
-}
-
-#[cfg(test)]
-pub(crate) mod tests {
-    use super::*;
-    use crate::e2e_tests;
-    use gpui::TestAppContext;
-    use serde_json::json;
-
-    crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow");
-
-    pub fn local_command() -> AgentServerCommand {
-        AgentServerCommand {
-            path: "claude".into(),
-            args: vec![],
-            env: None,
-        }
-    }
-
-    #[gpui::test]
-    #[cfg_attr(not(feature = "e2e"), ignore)]
-    async fn test_todo_plan(cx: &mut TestAppContext) {
-        let fs = e2e_tests::init_test(cx).await;
-        let project = Project::test(fs, [], cx).await;
-        let thread =
-            e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
-
-        thread
-            .update(cx, |thread, cx| {
-                thread.send_raw(
-                    "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.",
-                    cx,
-                )
-            })
-            .await
-            .unwrap();
-
-        let mut entries_len = 0;
-
-        thread.read_with(cx, |thread, _| {
-            entries_len = thread.plan().entries.len();
-            assert!(!thread.plan().entries.is_empty(), "Empty plan");
-        });
-
-        thread
-            .update(cx, |thread, cx| {
-                thread.send_raw(
-                    "Mark the first entry status as in progress without acting on it.",
-                    cx,
-                )
-            })
-            .await
-            .unwrap();
-
-        thread.read_with(cx, |thread, _| {
-            assert!(matches!(
-                thread.plan().entries[0].status,
-                acp::PlanEntryStatus::InProgress
-            ));
-            assert_eq!(thread.plan().entries.len(), entries_len);
-        });
-
-        thread
-            .update(cx, |thread, cx| {
-                thread.send_raw(
-                    "Now mark the first entry as completed without acting on it.",
-                    cx,
-                )
-            })
-            .await
-            .unwrap();
-
-        thread.read_with(cx, |thread, _| {
-            assert!(matches!(
-                thread.plan().entries[0].status,
-                acp::PlanEntryStatus::Completed
-            ));
-            assert_eq!(thread.plan().entries.len(), entries_len);
-        });
-    }
-
-    #[test]
-    fn test_deserialize_content_untagged_text() {
-        let json = json!("Hello, world!");
-        let content: Content = serde_json::from_value(json).unwrap();
-        match content {
-            Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
-            _ => panic!("Expected UntaggedText variant"),
-        }
-    }
-
-    #[test]
-    fn test_deserialize_content_chunks() {
-        let json = json!([
-            {
-                "type": "text",
-                "text": "Hello"
-            },
-            {
-                "type": "tool_use",
-                "id": "tool_123",
-                "name": "calculator",
-                "input": {"operation": "add", "a": 1, "b": 2}
-            }
-        ]);
-        let content: Content = serde_json::from_value(json).unwrap();
-        match content {
-            Content::Chunks(chunks) => {
-                assert_eq!(chunks.len(), 2);
-                match &chunks[0] {
-                    ContentChunk::Text { text } => assert_eq!(text, "Hello"),
-                    _ => panic!("Expected Text chunk"),
-                }
-                match &chunks[1] {
-                    ContentChunk::ToolUse { id, name, input } => {
-                        assert_eq!(id, "tool_123");
-                        assert_eq!(name, "calculator");
-                        assert_eq!(input["operation"], "add");
-                        assert_eq!(input["a"], 1);
-                        assert_eq!(input["b"], 2);
-                    }
-                    _ => panic!("Expected ToolUse chunk"),
-                }
-            }
-            _ => panic!("Expected Chunks variant"),
-        }
-    }
-
-    #[test]
-    fn test_deserialize_tool_result_untagged_text() {
-        let json = json!({
-            "type": "tool_result",
-            "content": "Result content",
-            "tool_use_id": "tool_456"
-        });
-        let chunk: ContentChunk = serde_json::from_value(json).unwrap();
-        match chunk {
-            ContentChunk::ToolResult {
-                content,
-                tool_use_id,
-            } => {
-                match content {
-                    Content::UntaggedText(text) => assert_eq!(text, "Result content"),
-                    _ => panic!("Expected UntaggedText content"),
-                }
-                assert_eq!(tool_use_id, "tool_456");
-            }
-            _ => panic!("Expected ToolResult variant"),
-        }
-    }
-
-    #[test]
-    fn test_deserialize_tool_result_chunks() {
-        let json = json!({
-            "type": "tool_result",
-            "content": [
-                {
-                    "type": "text",
-                    "text": "Processing complete"
-                },
-                {
-                    "type": "text",
-                    "text": "Result: 42"
-                }
-            ],
-            "tool_use_id": "tool_789"
-        });
-        let chunk: ContentChunk = serde_json::from_value(json).unwrap();
-        match chunk {
-            ContentChunk::ToolResult {
-                content,
-                tool_use_id,
-            } => {
-                match content {
-                    Content::Chunks(chunks) => {
-                        assert_eq!(chunks.len(), 2);
-                        match &chunks[0] {
-                            ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
-                            _ => panic!("Expected Text chunk"),
-                        }
-                        match &chunks[1] {
-                            ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
-                            _ => panic!("Expected Text chunk"),
-                        }
-                    }
-                    _ => panic!("Expected Chunks content"),
-                }
-                assert_eq!(tool_use_id, "tool_789");
-            }
-            _ => panic!("Expected ToolResult variant"),
-        }
-    }
-
-    #[test]
-    fn test_acp_content_to_claude() {
-        let acp_content = vec![
-            acp::ContentBlock::Text(acp::TextContent {
-                text: "Hello world".to_string(),
-                annotations: None,
-            }),
-            acp::ContentBlock::Image(acp::ImageContent {
-                data: "base64data".to_string(),
-                mime_type: "image/png".to_string(),
-                annotations: None,
-                uri: None,
-            }),
-            acp::ContentBlock::ResourceLink(acp::ResourceLink {
-                uri: "file:///path/to/example.rs".to_string(),
-                name: "example.rs".to_string(),
-                annotations: None,
-                description: None,
-                mime_type: None,
-                size: None,
-                title: None,
-            }),
-            acp::ContentBlock::Resource(acp::EmbeddedResource {
-                annotations: None,
-                resource: acp::EmbeddedResourceResource::TextResourceContents(
-                    acp::TextResourceContents {
-                        mime_type: None,
-                        text: "fn main() { println!(\"Hello!\"); }".to_string(),
-                        uri: "file:///path/to/code.rs".to_string(),
-                    },
-                ),
-            }),
-            acp::ContentBlock::ResourceLink(acp::ResourceLink {
-                uri: "invalid_uri_format".to_string(),
-                name: "invalid.txt".to_string(),
-                annotations: None,
-                description: None,
-                mime_type: None,
-                size: None,
-                title: None,
-            }),
-        ];
-
-        let claude_content = acp_content_to_claude(acp_content);
-
-        assert_eq!(claude_content.len(), 6);
-
-        match &claude_content[0] {
-            ContentChunk::Text { text } => assert_eq!(text, "Hello world"),
-            _ => panic!("Expected Text chunk"),
-        }
-
-        match &claude_content[1] {
-            ContentChunk::Image { source } => match source {
-                ImageSource::Base64 { data, media_type } => {
-                    assert_eq!(data, "base64data");
-                    assert_eq!(media_type, "image/png");
-                }
-                _ => panic!("Expected Base64 image source"),
-            },
-            _ => panic!("Expected Image chunk"),
-        }
-
-        match &claude_content[2] {
-            ContentChunk::Text { text } => {
-                assert!(text.contains("example.rs"));
-                assert!(text.contains("file:///path/to/example.rs"));
-            }
-            _ => panic!("Expected Text chunk for ResourceLink"),
-        }
-
-        match &claude_content[3] {
-            ContentChunk::Text { text } => {
-                assert!(text.contains("code.rs"));
-                assert!(text.contains("file:///path/to/code.rs"));
-            }
-            _ => panic!("Expected Text chunk for Resource"),
-        }
-
-        match &claude_content[4] {
-            ContentChunk::Text { text } => {
-                assert_eq!(text, "invalid_uri_format");
-            }
-            _ => panic!("Expected Text chunk for invalid URI"),
-        }
-
-        match &claude_content[5] {
-            ContentChunk::Text { text } => {
-                assert!(text.contains("<context ref=\"file:///path/to/code.rs\">"));
-                assert!(text.contains("fn main() { println!(\"Hello!\"); }"));
-                assert!(text.contains("</context>"));
-            }
-            _ => panic!("Expected Text chunk for context"),
-        }
-    }
-}

crates/agent_servers/src/claude/edit_tool.rs 🔗

@@ -1,178 +0,0 @@
-use acp_thread::AcpThread;
-use anyhow::Result;
-use context_server::{
-    listener::{McpServerTool, ToolResponse},
-    types::{ToolAnnotations, ToolResponseContent},
-};
-use gpui::{AsyncApp, WeakEntity};
-use language::unified_diff;
-use util::markdown::MarkdownCodeBlock;
-
-use crate::tools::EditToolParams;
-
-#[derive(Clone)]
-pub struct EditTool {
-    thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-impl EditTool {
-    pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
-        Self { thread_rx }
-    }
-}
-
-impl McpServerTool for EditTool {
-    type Input = EditToolParams;
-    type Output = ();
-
-    const NAME: &'static str = "Edit";
-
-    fn annotations(&self) -> ToolAnnotations {
-        ToolAnnotations {
-            title: Some("Edit file".to_string()),
-            read_only_hint: Some(false),
-            destructive_hint: Some(false),
-            open_world_hint: Some(false),
-            idempotent_hint: Some(false),
-        }
-    }
-
-    async fn run(
-        &self,
-        input: Self::Input,
-        cx: &mut AsyncApp,
-    ) -> Result<ToolResponse<Self::Output>> {
-        let mut thread_rx = self.thread_rx.clone();
-        let Some(thread) = thread_rx.recv().await?.upgrade() else {
-            anyhow::bail!("Thread closed");
-        };
-
-        let content = thread
-            .update(cx, |thread, cx| {
-                thread.read_text_file(input.abs_path.clone(), None, None, true, cx)
-            })?
-            .await?;
-
-        let (new_content, diff) = cx
-            .background_executor()
-            .spawn(async move {
-                let new_content = content.replace(&input.old_text, &input.new_text);
-                if new_content == content {
-                    return Err(anyhow::anyhow!("Failed to find `old_text`",));
-                }
-                let diff = unified_diff(&content, &new_content);
-
-                Ok((new_content, diff))
-            })
-            .await?;
-
-        thread
-            .update(cx, |thread, cx| {
-                thread.write_text_file(input.abs_path, new_content, cx)
-            })?
-            .await?;
-
-        Ok(ToolResponse {
-            content: vec![ToolResponseContent::Text {
-                text: MarkdownCodeBlock {
-                    tag: "diff",
-                    text: diff.as_str().trim_end_matches('\n'),
-                }
-                .to_string(),
-            }],
-            structured_content: (),
-        })
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::rc::Rc;
-
-    use acp_thread::{AgentConnection, StubAgentConnection};
-    use gpui::{Entity, TestAppContext};
-    use indoc::indoc;
-    use project::{FakeFs, Project};
-    use serde_json::json;
-    use settings::SettingsStore;
-    use util::path;
-
-    use super::*;
-
-    #[gpui::test]
-    async fn old_text_not_found(cx: &mut TestAppContext) {
-        let (_thread, tool) = init_test(cx).await;
-
-        let result = tool
-            .run(
-                EditToolParams {
-                    abs_path: path!("/root/file.txt").into(),
-                    old_text: "hi".into(),
-                    new_text: "bye".into(),
-                },
-                &mut cx.to_async(),
-            )
-            .await;
-
-        assert_eq!(result.unwrap_err().to_string(), "Failed to find `old_text`");
-    }
-
-    #[gpui::test]
-    async fn found_and_replaced(cx: &mut TestAppContext) {
-        let (_thread, tool) = init_test(cx).await;
-
-        let result = tool
-            .run(
-                EditToolParams {
-                    abs_path: path!("/root/file.txt").into(),
-                    old_text: "hello".into(),
-                    new_text: "hi".into(),
-                },
-                &mut cx.to_async(),
-            )
-            .await;
-
-        assert_eq!(
-            result.unwrap().content[0].text().unwrap(),
-            indoc! {
-                r"
-                ```diff
-                @@ -1,1 +1,1 @@
-                -hello
-                +hi
-                ```
-                "
-            }
-        );
-    }
-
-    async fn init_test(cx: &mut TestAppContext) -> (Entity<AcpThread>, EditTool) {
-        cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            language::init(cx);
-            Project::init_settings(cx);
-        });
-
-        let connection = Rc::new(StubAgentConnection::new());
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/root"),
-            json!({
-                "file.txt": "hello"
-            }),
-        )
-        .await;
-        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
-        let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
-
-        let thread = cx
-            .update(|cx| connection.new_thread(project, path!("/test").as_ref(), cx))
-            .await
-            .unwrap();
-
-        thread_tx.send(thread.downgrade()).unwrap();
-
-        (thread, EditTool::new(thread_rx))
-    }
-}

crates/agent_servers/src/claude/mcp_server.rs 🔗

@@ -1,99 +0,0 @@
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use crate::claude::edit_tool::EditTool;
-use crate::claude::permission_tool::PermissionTool;
-use crate::claude::read_tool::ReadTool;
-use crate::claude::write_tool::WriteTool;
-use acp_thread::AcpThread;
-#[cfg(not(test))]
-use anyhow::Context as _;
-use anyhow::Result;
-use collections::HashMap;
-use context_server::types::{
-    Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities,
-    ToolsCapabilities, requests,
-};
-use gpui::{App, AsyncApp, Task, WeakEntity};
-use project::Fs;
-use serde::Serialize;
-
-pub struct ClaudeZedMcpServer {
-    server: context_server::listener::McpServer,
-}
-
-pub const SERVER_NAME: &str = "zed";
-
-impl ClaudeZedMcpServer {
-    pub async fn new(
-        thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-        fs: Arc<dyn Fs>,
-        cx: &AsyncApp,
-    ) -> Result<Self> {
-        let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
-        mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
-
-        mcp_server.add_tool(PermissionTool::new(fs.clone(), thread_rx.clone()));
-        mcp_server.add_tool(ReadTool::new(thread_rx.clone()));
-        mcp_server.add_tool(EditTool::new(thread_rx.clone()));
-        mcp_server.add_tool(WriteTool::new(thread_rx.clone()));
-
-        Ok(Self { server: mcp_server })
-    }
-
-    pub fn server_config(&self) -> Result<McpServerConfig> {
-        #[cfg(not(test))]
-        let zed_path = std::env::current_exe()
-            .context("finding current executable path for use in mcp_server")?;
-
-        #[cfg(test)]
-        let zed_path = crate::e2e_tests::get_zed_path();
-
-        Ok(McpServerConfig {
-            command: zed_path,
-            args: vec![
-                "--nc".into(),
-                self.server.socket_path().display().to_string(),
-            ],
-            env: None,
-        })
-    }
-
-    fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
-        cx.foreground_executor().spawn(async move {
-            Ok(InitializeResponse {
-                protocol_version: ProtocolVersion("2025-06-18".into()),
-                capabilities: ServerCapabilities {
-                    experimental: None,
-                    logging: None,
-                    completions: None,
-                    prompts: None,
-                    resources: None,
-                    tools: Some(ToolsCapabilities {
-                        list_changed: Some(false),
-                    }),
-                },
-                server_info: Implementation {
-                    name: SERVER_NAME.into(),
-                    version: "0.1.0".into(),
-                },
-                meta: None,
-            })
-        })
-    }
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct McpConfig {
-    pub mcp_servers: HashMap<String, McpServerConfig>,
-}
-
-#[derive(Serialize, Clone)]
-#[serde(rename_all = "camelCase")]
-pub struct McpServerConfig {
-    pub command: PathBuf,
-    pub args: Vec<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub env: Option<HashMap<String, String>>,
-}

crates/agent_servers/src/claude/permission_tool.rs 🔗

@@ -1,158 +0,0 @@
-use std::sync::Arc;
-
-use acp_thread::AcpThread;
-use agent_client_protocol as acp;
-use agent_settings::AgentSettings;
-use anyhow::{Context as _, Result};
-use context_server::{
-    listener::{McpServerTool, ToolResponse},
-    types::ToolResponseContent,
-};
-use gpui::{AsyncApp, WeakEntity};
-use project::Fs;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use settings::{Settings as _, update_settings_file};
-use util::debug_panic;
-
-use crate::tools::ClaudeTool;
-
-#[derive(Clone)]
-pub struct PermissionTool {
-    fs: Arc<dyn Fs>,
-    thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-/// Request permission for tool calls
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct PermissionToolParams {
-    tool_name: String,
-    input: serde_json::Value,
-    tool_use_id: Option<String>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct PermissionToolResponse {
-    behavior: PermissionToolBehavior,
-    updated_input: serde_json::Value,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "snake_case")]
-enum PermissionToolBehavior {
-    Allow,
-    Deny,
-}
-
-impl PermissionTool {
-    pub fn new(fs: Arc<dyn Fs>, thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
-        Self { fs, thread_rx }
-    }
-}
-
-impl McpServerTool for PermissionTool {
-    type Input = PermissionToolParams;
-    type Output = ();
-
-    const NAME: &'static str = "Confirmation";
-
-    async fn run(
-        &self,
-        input: Self::Input,
-        cx: &mut AsyncApp,
-    ) -> Result<ToolResponse<Self::Output>> {
-        if agent_settings::AgentSettings::try_read_global(cx, |settings| {
-            settings.always_allow_tool_actions
-        })
-        .unwrap_or(false)
-        {
-            let response = PermissionToolResponse {
-                behavior: PermissionToolBehavior::Allow,
-                updated_input: input.input,
-            };
-
-            return Ok(ToolResponse {
-                content: vec![ToolResponseContent::Text {
-                    text: serde_json::to_string(&response)?,
-                }],
-                structured_content: (),
-            });
-        }
-
-        let mut thread_rx = self.thread_rx.clone();
-        let Some(thread) = thread_rx.recv().await?.upgrade() else {
-            anyhow::bail!("Thread closed");
-        };
-
-        let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone());
-        let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into());
-
-        const ALWAYS_ALLOW: &str = "always_allow";
-        const ALLOW: &str = "allow";
-        const REJECT: &str = "reject";
-
-        let chosen_option = thread
-            .update(cx, |thread, cx| {
-                thread.request_tool_call_authorization(
-                    claude_tool.as_acp(tool_call_id).into(),
-                    vec![
-                        acp::PermissionOption {
-                            id: acp::PermissionOptionId(ALWAYS_ALLOW.into()),
-                            name: "Always Allow".into(),
-                            kind: acp::PermissionOptionKind::AllowAlways,
-                        },
-                        acp::PermissionOption {
-                            id: acp::PermissionOptionId(ALLOW.into()),
-                            name: "Allow".into(),
-                            kind: acp::PermissionOptionKind::AllowOnce,
-                        },
-                        acp::PermissionOption {
-                            id: acp::PermissionOptionId(REJECT.into()),
-                            name: "Reject".into(),
-                            kind: acp::PermissionOptionKind::RejectOnce,
-                        },
-                    ],
-                    cx,
-                )
-            })??
-            .await?;
-
-        let response = match chosen_option.0.as_ref() {
-            ALWAYS_ALLOW => {
-                cx.update(|cx| {
-                    update_settings_file::<AgentSettings>(self.fs.clone(), cx, |settings, _| {
-                        settings.set_always_allow_tool_actions(true);
-                    });
-                })?;
-
-                PermissionToolResponse {
-                    behavior: PermissionToolBehavior::Allow,
-                    updated_input: input.input,
-                }
-            }
-            ALLOW => PermissionToolResponse {
-                behavior: PermissionToolBehavior::Allow,
-                updated_input: input.input,
-            },
-            REJECT => PermissionToolResponse {
-                behavior: PermissionToolBehavior::Deny,
-                updated_input: input.input,
-            },
-            opt => {
-                debug_panic!("Unexpected option: {}", opt);
-                PermissionToolResponse {
-                    behavior: PermissionToolBehavior::Deny,
-                    updated_input: input.input,
-                }
-            }
-        };
-
-        Ok(ToolResponse {
-            content: vec![ToolResponseContent::Text {
-                text: serde_json::to_string(&response)?,
-            }],
-            structured_content: (),
-        })
-    }
-}

crates/agent_servers/src/claude/read_tool.rs 🔗

@@ -1,59 +0,0 @@
-use acp_thread::AcpThread;
-use anyhow::Result;
-use context_server::{
-    listener::{McpServerTool, ToolResponse},
-    types::{ToolAnnotations, ToolResponseContent},
-};
-use gpui::{AsyncApp, WeakEntity};
-
-use crate::tools::ReadToolParams;
-
-#[derive(Clone)]
-pub struct ReadTool {
-    thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-impl ReadTool {
-    pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
-        Self { thread_rx }
-    }
-}
-
-impl McpServerTool for ReadTool {
-    type Input = ReadToolParams;
-    type Output = ();
-
-    const NAME: &'static str = "Read";
-
-    fn annotations(&self) -> ToolAnnotations {
-        ToolAnnotations {
-            title: Some("Read file".to_string()),
-            read_only_hint: Some(true),
-            destructive_hint: Some(false),
-            open_world_hint: Some(false),
-            idempotent_hint: None,
-        }
-    }
-
-    async fn run(
-        &self,
-        input: Self::Input,
-        cx: &mut AsyncApp,
-    ) -> Result<ToolResponse<Self::Output>> {
-        let mut thread_rx = self.thread_rx.clone();
-        let Some(thread) = thread_rx.recv().await?.upgrade() else {
-            anyhow::bail!("Thread closed");
-        };
-
-        let content = thread
-            .update(cx, |thread, cx| {
-                thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx)
-            })?
-            .await?;
-
-        Ok(ToolResponse {
-            content: vec![ToolResponseContent::Text { text: content }],
-            structured_content: (),
-        })
-    }
-}

crates/agent_servers/src/claude/tools.rs 🔗

@@ -1,688 +0,0 @@
-use std::path::PathBuf;
-
-use agent_client_protocol as acp;
-use itertools::Itertools;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use util::ResultExt;
-
-pub enum ClaudeTool {
-    Task(Option<TaskToolParams>),
-    NotebookRead(Option<NotebookReadToolParams>),
-    NotebookEdit(Option<NotebookEditToolParams>),
-    Edit(Option<EditToolParams>),
-    MultiEdit(Option<MultiEditToolParams>),
-    ReadFile(Option<ReadToolParams>),
-    Write(Option<WriteToolParams>),
-    Ls(Option<LsToolParams>),
-    Glob(Option<GlobToolParams>),
-    Grep(Option<GrepToolParams>),
-    Terminal(Option<BashToolParams>),
-    WebFetch(Option<WebFetchToolParams>),
-    WebSearch(Option<WebSearchToolParams>),
-    TodoWrite(Option<TodoWriteToolParams>),
-    ExitPlanMode(Option<ExitPlanModeToolParams>),
-    Other {
-        name: String,
-        input: serde_json::Value,
-    },
-}
-
-impl ClaudeTool {
-    pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
-        match tool_name {
-            // Known tools
-            "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
-            "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
-            "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()),
-            "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
-            "Write" => Self::Write(serde_json::from_value(input).log_err()),
-            "LS" => Self::Ls(serde_json::from_value(input).log_err()),
-            "Glob" => Self::Glob(serde_json::from_value(input).log_err()),
-            "Grep" => Self::Grep(serde_json::from_value(input).log_err()),
-            "Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
-            "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
-            "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
-            "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
-            "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
-            "Task" => Self::Task(serde_json::from_value(input).log_err()),
-            "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
-            "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
-            // Inferred from name
-            _ => {
-                let tool_name = tool_name.to_lowercase();
-
-                if tool_name.contains("edit") || tool_name.contains("write") {
-                    Self::Edit(None)
-                } else if tool_name.contains("terminal") {
-                    Self::Terminal(None)
-                } else {
-                    Self::Other {
-                        name: tool_name,
-                        input,
-                    }
-                }
-            }
-        }
-    }
-
-    pub fn label(&self) -> String {
-        match &self {
-            Self::Task(Some(params)) => params.description.clone(),
-            Self::Task(None) => "Task".into(),
-            Self::NotebookRead(Some(params)) => {
-                format!("Read Notebook {}", params.notebook_path.display())
-            }
-            Self::NotebookRead(None) => "Read Notebook".into(),
-            Self::NotebookEdit(Some(params)) => {
-                format!("Edit Notebook {}", params.notebook_path.display())
-            }
-            Self::NotebookEdit(None) => "Edit Notebook".into(),
-            Self::Terminal(Some(params)) => format!("`{}`", params.command),
-            Self::Terminal(None) => "Terminal".into(),
-            Self::ReadFile(_) => "Read File".into(),
-            Self::Ls(Some(params)) => {
-                format!("List Directory {}", params.path.display())
-            }
-            Self::Ls(None) => "List Directory".into(),
-            Self::Edit(Some(params)) => {
-                format!("Edit {}", params.abs_path.display())
-            }
-            Self::Edit(None) => "Edit".into(),
-            Self::MultiEdit(Some(params)) => {
-                format!("Multi Edit {}", params.file_path.display())
-            }
-            Self::MultiEdit(None) => "Multi Edit".into(),
-            Self::Write(Some(params)) => {
-                format!("Write {}", params.abs_path.display())
-            }
-            Self::Write(None) => "Write".into(),
-            Self::Glob(Some(params)) => {
-                format!("Glob `{params}`")
-            }
-            Self::Glob(None) => "Glob".into(),
-            Self::Grep(Some(params)) => format!("`{params}`"),
-            Self::Grep(None) => "Grep".into(),
-            Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
-            Self::WebFetch(None) => "Fetch".into(),
-            Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
-            Self::WebSearch(None) => "Web Search".into(),
-            Self::TodoWrite(Some(params)) => format!(
-                "Update TODOs: {}",
-                params.todos.iter().map(|todo| &todo.content).join(", ")
-            ),
-            Self::TodoWrite(None) => "Update TODOs".into(),
-            Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
-            Self::Other { name, .. } => name.clone(),
-        }
-    }
-    pub fn content(&self) -> Vec<acp::ToolCallContent> {
-        match &self {
-            Self::Other { input, .. } => vec![
-                format!(
-                    "```json\n{}```",
-                    serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
-                )
-                .into(),
-            ],
-            Self::Task(Some(params)) => vec![params.prompt.clone().into()],
-            Self::NotebookRead(Some(params)) => {
-                vec![params.notebook_path.display().to_string().into()]
-            }
-            Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()],
-            Self::Terminal(Some(params)) => vec![
-                format!(
-                    "`{}`\n\n{}",
-                    params.command,
-                    params.description.as_deref().unwrap_or_default()
-                )
-                .into(),
-            ],
-            Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()],
-            Self::Ls(Some(params)) => vec![params.path.display().to_string().into()],
-            Self::Glob(Some(params)) => vec![params.to_string().into()],
-            Self::Grep(Some(params)) => vec![format!("`{params}`").into()],
-            Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()],
-            Self::WebSearch(Some(params)) => vec![params.to_string().into()],
-            Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()],
-            Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff {
-                diff: acp::Diff {
-                    path: params.abs_path.clone(),
-                    old_text: Some(params.old_text.clone()),
-                    new_text: params.new_text.clone(),
-                },
-            }],
-            Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
-                diff: acp::Diff {
-                    path: params.abs_path.clone(),
-                    old_text: None,
-                    new_text: params.content.clone(),
-                },
-            }],
-            Self::MultiEdit(Some(params)) => {
-                // todo: show multiple edits in a multibuffer?
-                params
-                    .edits
-                    .first()
-                    .map(|edit| {
-                        vec![acp::ToolCallContent::Diff {
-                            diff: acp::Diff {
-                                path: params.file_path.clone(),
-                                old_text: Some(edit.old_string.clone()),
-                                new_text: edit.new_string.clone(),
-                            },
-                        }]
-                    })
-                    .unwrap_or_default()
-            }
-            Self::TodoWrite(Some(_)) => {
-                // These are mapped to plan updates later
-                vec![]
-            }
-            Self::Task(None)
-            | Self::NotebookRead(None)
-            | Self::NotebookEdit(None)
-            | Self::Terminal(None)
-            | Self::ReadFile(None)
-            | Self::Ls(None)
-            | Self::Glob(None)
-            | Self::Grep(None)
-            | Self::WebFetch(None)
-            | Self::WebSearch(None)
-            | Self::TodoWrite(None)
-            | Self::ExitPlanMode(None)
-            | Self::Edit(None)
-            | Self::Write(None)
-            | Self::MultiEdit(None) => vec![],
-        }
-    }
-
-    pub fn kind(&self) -> acp::ToolKind {
-        match self {
-            Self::Task(_) => acp::ToolKind::Think,
-            Self::NotebookRead(_) => acp::ToolKind::Read,
-            Self::NotebookEdit(_) => acp::ToolKind::Edit,
-            Self::Edit(_) => acp::ToolKind::Edit,
-            Self::MultiEdit(_) => acp::ToolKind::Edit,
-            Self::Write(_) => acp::ToolKind::Edit,
-            Self::ReadFile(_) => acp::ToolKind::Read,
-            Self::Ls(_) => acp::ToolKind::Search,
-            Self::Glob(_) => acp::ToolKind::Search,
-            Self::Grep(_) => acp::ToolKind::Search,
-            Self::Terminal(_) => acp::ToolKind::Execute,
-            Self::WebSearch(_) => acp::ToolKind::Search,
-            Self::WebFetch(_) => acp::ToolKind::Fetch,
-            Self::TodoWrite(_) => acp::ToolKind::Think,
-            Self::ExitPlanMode(_) => acp::ToolKind::Think,
-            Self::Other { .. } => acp::ToolKind::Other,
-        }
-    }
-
-    pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
-        match &self {
-            Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
-                path: abs_path.clone(),
-                line: None,
-            }],
-            Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
-                vec![acp::ToolCallLocation {
-                    path: file_path.clone(),
-                    line: None,
-                }]
-            }
-            Self::Write(Some(WriteToolParams {
-                abs_path: file_path,
-                ..
-            })) => {
-                vec![acp::ToolCallLocation {
-                    path: file_path.clone(),
-                    line: None,
-                }]
-            }
-            Self::ReadFile(Some(ReadToolParams {
-                abs_path, offset, ..
-            })) => vec![acp::ToolCallLocation {
-                path: abs_path.clone(),
-                line: *offset,
-            }],
-            Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
-                vec![acp::ToolCallLocation {
-                    path: notebook_path.clone(),
-                    line: None,
-                }]
-            }
-            Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
-                vec![acp::ToolCallLocation {
-                    path: notebook_path.clone(),
-                    line: None,
-                }]
-            }
-            Self::Glob(Some(GlobToolParams {
-                path: Some(path), ..
-            })) => vec![acp::ToolCallLocation {
-                path: path.clone(),
-                line: None,
-            }],
-            Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
-                path: path.clone(),
-                line: None,
-            }],
-            Self::Grep(Some(GrepToolParams {
-                path: Some(path), ..
-            })) => vec![acp::ToolCallLocation {
-                path: PathBuf::from(path),
-                line: None,
-            }],
-            Self::Task(_)
-            | Self::NotebookRead(None)
-            | Self::NotebookEdit(None)
-            | Self::Edit(None)
-            | Self::MultiEdit(None)
-            | Self::Write(None)
-            | Self::ReadFile(None)
-            | Self::Ls(None)
-            | Self::Glob(_)
-            | Self::Grep(_)
-            | Self::Terminal(_)
-            | Self::WebFetch(_)
-            | Self::WebSearch(_)
-            | Self::TodoWrite(_)
-            | Self::ExitPlanMode(_)
-            | Self::Other { .. } => vec![],
-        }
-    }
-
-    pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
-        acp::ToolCall {
-            id,
-            kind: self.kind(),
-            status: acp::ToolCallStatus::InProgress,
-            title: self.label(),
-            content: self.content(),
-            locations: self.locations(),
-            raw_input: None,
-            raw_output: None,
-        }
-    }
-}
-
-/// Edit a file.
-///
-/// In sessions with mcp__zed__Edit always use it instead of Edit as it will
-/// allow the user to conveniently review changes.
-///
-/// File editing instructions:
-/// - The `old_text` param must match existing file content, including indentation.
-/// - The `old_text` param must come from the actual file, not an outline.
-/// - The `old_text` section must not be empty.
-/// - Be minimal with replacements:
-///     - For unique lines, include only those lines.
-///     - For non-unique lines, include enough context to identify them.
-/// - Do not escape quotes, newlines, or other characters.
-/// - Only edit the specified file.
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct EditToolParams {
-    /// The absolute path to the file to read.
-    pub abs_path: PathBuf,
-    /// The old text to replace (must be unique in the file)
-    pub old_text: String,
-    /// The new text.
-    pub new_text: String,
-}
-
-/// Reads the content of the given file in the project.
-///
-/// Never attempt to read a path that hasn't been previously mentioned.
-///
-/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct ReadToolParams {
-    /// The absolute path to the file to read.
-    pub abs_path: PathBuf,
-    /// Which line to start reading from. Omit to start from the beginning.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub offset: Option<u32>,
-    /// How many lines to read. Omit for the whole file.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub limit: Option<u32>,
-}
-
-/// Writes content to the specified file in the project.
-///
-/// In sessions with mcp__zed__Write always use it instead of Write as it will
-/// allow the user to conveniently review changes.
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct WriteToolParams {
-    /// The absolute path of the file to write.
-    pub abs_path: PathBuf,
-    /// The full content to write.
-    pub content: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct BashToolParams {
-    /// Shell command to execute
-    pub command: String,
-    /// 5-10 word description of what command does
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub description: Option<String>,
-    /// Timeout in ms (max 600000ms/10min, default 120000ms)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub timeout: Option<u32>,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct GlobToolParams {
-    /// Glob pattern like **/*.js or src/**/*.ts
-    pub pattern: String,
-    /// Directory to search in (omit for current directory)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub path: Option<PathBuf>,
-}
-
-impl std::fmt::Display for GlobToolParams {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        if let Some(path) = &self.path {
-            write!(f, "{}", path.display())?;
-        }
-        write!(f, "{}", self.pattern)
-    }
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct LsToolParams {
-    /// Absolute path to directory
-    pub path: PathBuf,
-    /// Array of glob patterns to ignore
-    #[serde(default, skip_serializing_if = "Vec::is_empty")]
-    pub ignore: Vec<String>,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct GrepToolParams {
-    /// Regex pattern to search for
-    pub pattern: String,
-    /// File/directory to search (defaults to current directory)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub path: Option<String>,
-    /// "content" (shows lines), "files_with_matches" (default), "count"
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub output_mode: Option<GrepOutputMode>,
-    /// Filter files with glob pattern like "*.js"
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub glob: Option<String>,
-    /// File type filter like "js", "py", "rust"
-    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
-    pub file_type: Option<String>,
-    /// Case insensitive search
-    #[serde(rename = "-i", default, skip_serializing_if = "is_false")]
-    pub case_insensitive: bool,
-    /// Show line numbers (content mode only)
-    #[serde(rename = "-n", default, skip_serializing_if = "is_false")]
-    pub line_numbers: bool,
-    /// Lines after match (content mode only)
-    #[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
-    pub after_context: Option<u32>,
-    /// Lines before match (content mode only)
-    #[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
-    pub before_context: Option<u32>,
-    /// Lines before and after match (content mode only)
-    #[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
-    pub context: Option<u32>,
-    /// Enable multiline/cross-line matching
-    #[serde(default, skip_serializing_if = "is_false")]
-    pub multiline: bool,
-    /// Limit output to first N results
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub head_limit: Option<u32>,
-}
-
-impl std::fmt::Display for GrepToolParams {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "grep")?;
-
-        // Boolean flags
-        if self.case_insensitive {
-            write!(f, " -i")?;
-        }
-        if self.line_numbers {
-            write!(f, " -n")?;
-        }
-
-        // Context options
-        if let Some(after) = self.after_context {
-            write!(f, " -A {}", after)?;
-        }
-        if let Some(before) = self.before_context {
-            write!(f, " -B {}", before)?;
-        }
-        if let Some(context) = self.context {
-            write!(f, " -C {}", context)?;
-        }
-
-        // Output mode
-        if let Some(mode) = &self.output_mode {
-            match mode {
-                GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
-                GrepOutputMode::Count => write!(f, " -c")?,
-                GrepOutputMode::Content => {} // Default mode
-            }
-        }
-
-        // Head limit
-        if let Some(limit) = self.head_limit {
-            write!(f, " | head -{}", limit)?;
-        }
-
-        // Glob pattern
-        if let Some(glob) = &self.glob {
-            write!(f, " --include=\"{}\"", glob)?;
-        }
-
-        // File type
-        if let Some(file_type) = &self.file_type {
-            write!(f, " --type={}", file_type)?;
-        }
-
-        // Multiline
-        if self.multiline {
-            write!(f, " -P")?; // Perl-compatible regex for multiline
-        }
-
-        // Pattern (escaped if contains special characters)
-        write!(f, " \"{}\"", self.pattern)?;
-
-        // Path
-        if let Some(path) = &self.path {
-            write!(f, " {}", path)?;
-        }
-
-        Ok(())
-    }
-}
-
-#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum TodoPriority {
-    High,
-    #[default]
-    Medium,
-    Low,
-}
-
-impl Into<acp::PlanEntryPriority> for TodoPriority {
-    fn into(self) -> acp::PlanEntryPriority {
-        match self {
-            TodoPriority::High => acp::PlanEntryPriority::High,
-            TodoPriority::Medium => acp::PlanEntryPriority::Medium,
-            TodoPriority::Low => acp::PlanEntryPriority::Low,
-        }
-    }
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum TodoStatus {
-    Pending,
-    InProgress,
-    Completed,
-}
-
-impl Into<acp::PlanEntryStatus> for TodoStatus {
-    fn into(self) -> acp::PlanEntryStatus {
-        match self {
-            TodoStatus::Pending => acp::PlanEntryStatus::Pending,
-            TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
-            TodoStatus::Completed => acp::PlanEntryStatus::Completed,
-        }
-    }
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-pub struct Todo {
-    /// Task description
-    pub content: String,
-    /// Current status of the todo
-    pub status: TodoStatus,
-    /// Priority level of the todo
-    #[serde(default)]
-    pub priority: TodoPriority,
-}
-
-impl Into<acp::PlanEntry> for Todo {
-    fn into(self) -> acp::PlanEntry {
-        acp::PlanEntry {
-            content: self.content,
-            priority: self.priority.into(),
-            status: self.status.into(),
-        }
-    }
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct TodoWriteToolParams {
-    pub todos: Vec<Todo>,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct ExitPlanModeToolParams {
-    /// Implementation plan in markdown format
-    pub plan: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct TaskToolParams {
-    /// Short 3-5 word description of task
-    pub description: String,
-    /// Detailed task for agent to perform
-    pub prompt: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct NotebookReadToolParams {
-    /// Absolute path to .ipynb file
-    pub notebook_path: PathBuf,
-    /// Specific cell ID to read
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub cell_id: Option<String>,
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum CellType {
-    Code,
-    Markdown,
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum EditMode {
-    Replace,
-    Insert,
-    Delete,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct NotebookEditToolParams {
-    /// Absolute path to .ipynb file
-    pub notebook_path: PathBuf,
-    /// New cell content
-    pub new_source: String,
-    /// Cell ID to edit
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub cell_id: Option<String>,
-    /// Type of cell (code or markdown)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub cell_type: Option<CellType>,
-    /// Edit operation mode
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub edit_mode: Option<EditMode>,
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-pub struct MultiEditItem {
-    /// The text to search for and replace
-    pub old_string: String,
-    /// The replacement text
-    pub new_string: String,
-    /// Whether to replace all occurrences or just the first
-    #[serde(default, skip_serializing_if = "is_false")]
-    pub replace_all: bool,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct MultiEditToolParams {
-    /// Absolute path to file
-    pub file_path: PathBuf,
-    /// List of edits to apply
-    pub edits: Vec<MultiEditItem>,
-}
-
-fn is_false(v: &bool) -> bool {
-    !*v
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum GrepOutputMode {
-    Content,
-    FilesWithMatches,
-    Count,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct WebFetchToolParams {
-    /// Valid URL to fetch
-    #[serde(rename = "url")]
-    pub url: String,
-    /// What to extract from content
-    pub prompt: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct WebSearchToolParams {
-    /// Search query (min 2 chars)
-    pub query: String,
-    /// Only include these domains
-    #[serde(default, skip_serializing_if = "Vec::is_empty")]
-    pub allowed_domains: Vec<String>,
-    /// Exclude these domains
-    #[serde(default, skip_serializing_if = "Vec::is_empty")]
-    pub blocked_domains: Vec<String>,
-}
-
-impl std::fmt::Display for WebSearchToolParams {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "\"{}\"", self.query)?;
-
-        if !self.allowed_domains.is_empty() {
-            write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
-        }
-
-        if !self.blocked_domains.is_empty() {
-            write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
-        }
-
-        Ok(())
-    }
-}

crates/agent_servers/src/claude/write_tool.rs 🔗

@@ -1,59 +0,0 @@
-use acp_thread::AcpThread;
-use anyhow::Result;
-use context_server::{
-    listener::{McpServerTool, ToolResponse},
-    types::ToolAnnotations,
-};
-use gpui::{AsyncApp, WeakEntity};
-
-use crate::tools::WriteToolParams;
-
-#[derive(Clone)]
-pub struct WriteTool {
-    thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-impl WriteTool {
-    pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
-        Self { thread_rx }
-    }
-}
-
-impl McpServerTool for WriteTool {
-    type Input = WriteToolParams;
-    type Output = ();
-
-    const NAME: &'static str = "Write";
-
-    fn annotations(&self) -> ToolAnnotations {
-        ToolAnnotations {
-            title: Some("Write file".to_string()),
-            read_only_hint: Some(false),
-            destructive_hint: Some(false),
-            open_world_hint: Some(false),
-            idempotent_hint: Some(false),
-        }
-    }
-
-    async fn run(
-        &self,
-        input: Self::Input,
-        cx: &mut AsyncApp,
-    ) -> Result<ToolResponse<Self::Output>> {
-        let mut thread_rx = self.thread_rx.clone();
-        let Some(thread) = thread_rx.recv().await?.upgrade() else {
-            anyhow::bail!("Thread closed");
-        };
-
-        thread
-            .update(cx, |thread, cx| {
-                thread.write_text_file(input.abs_path, input.content, cx)
-            })?
-            .await?;
-
-        Ok(ToolResponse {
-            content: vec![],
-            structured_content: (),
-        })
-    }
-}

crates/agent_servers/src/custom.rs 🔗

@@ -2,7 +2,6 @@ use crate::{AgentServerCommand, AgentServerDelegate};
 use acp_thread::AgentConnection;
 use anyhow::Result;
 use gpui::{App, SharedString, Task};
-use language_models::provider::anthropic::AnthropicLanguageModelProvider;
 use std::{path::Path, rc::Rc};
 use ui::IconName;
 
@@ -38,24 +37,9 @@ impl crate::AgentServer for CustomAgentServer {
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
         let server_name = self.name();
-        let mut command = self.command.clone();
+        let command = self.command.clone();
         let root_dir = root_dir.to_path_buf();
-
-        // TODO: Remove this once we have Claude properly
-        cx.spawn(async move |mut cx| {
-            if let Some(api_key) = cx
-                .update(AnthropicLanguageModelProvider::api_key)?
-                .await
-                .ok()
-            {
-                command
-                    .env
-                    .get_or_insert_default()
-                    .insert("ANTHROPIC_API_KEY".to_owned(), api_key.key);
-            }
-
-            crate::acp::connect(server_name, command, &root_dir, &mut cx).await
-        })
+        cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await)
     }
 
     fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -1,4 +1,6 @@
 use crate::{AgentServer, AgentServerDelegate};
+#[cfg(test)]
+use crate::{AgentServerCommand, CustomAgentServerSettings};
 use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
 use agent_client_protocol as acp;
 use futures::{FutureExt, StreamExt, channel::mpsc, select};
@@ -471,7 +473,13 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
         #[cfg(test)]
         crate::AllAgentServersSettings::override_global(
             crate::AllAgentServersSettings {
-                claude: Some(crate::claude::tests::local_command().into()),
+                claude: Some(CustomAgentServerSettings {
+                    command: AgentServerCommand {
+                        path: "claude-code-acp".into(),
+                        args: vec![],
+                        env: None,
+                    },
+                }),
                 gemini: Some(crate::gemini::tests::local_command().into()),
                 custom: collections::HashMap::default(),
             },

crates/agent_servers/src/gemini.rs 🔗

@@ -5,7 +5,7 @@ use crate::acp::AcpConnection;
 use crate::{AgentServer, AgentServerDelegate};
 use acp_thread::{AgentConnection, LoadError};
 use anyhow::Result;
-use gpui::{App, SharedString, Task};
+use gpui::{App, AppContext as _, SharedString, Task};
 use language_models::provider::google::GoogleLanguageModelProvider;
 use settings::SettingsStore;
 
@@ -37,23 +37,32 @@ impl AgentServer for Gemini {
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
         let root_dir = root_dir.to_path_buf();
         let server_name = self.name();
-        cx.spawn(async move |cx| {
-            let settings = cx.read_global(|settings: &SettingsStore, _| {
-                settings.get::<AllAgentServersSettings>(None).gemini.clone()
-            })?;
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(None).gemini.clone()
+        });
 
-            let mut command = cx
-                .update(|cx| {
+        cx.spawn(async move |cx| {
+            let ignore_system_version = settings
+                .as_ref()
+                .and_then(|settings| settings.ignore_system_version)
+                .unwrap_or(true);
+            let mut command = if let Some(settings) = settings
+                && let Some(command) = settings.custom_command()
+            {
+                command
+            } else {
+                cx.update(|cx| {
                     delegate.get_or_npm_install_builtin_agent(
                         Self::BINARY_NAME.into(),
                         Self::PACKAGE_NAME.into(),
                         format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
-                        settings,
-                        Some("0.2.1".parse().unwrap()),
+                        ignore_system_version,
+                        Some(Self::MINIMUM_VERSION.parse().unwrap()),
                         cx,
                     )
                 })?
-                .await?;
+                .await?
+            };
             command.args.push("--experimental-acp".into());
 
             if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {

crates/agent_servers/src/settings.rs 🔗

@@ -15,7 +15,7 @@ pub fn init(cx: &mut App) {
 #[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
 pub struct AllAgentServersSettings {
     pub gemini: Option<BuiltinAgentServerSettings>,
-    pub claude: Option<BuiltinAgentServerSettings>,
+    pub claude: Option<CustomAgentServerSettings>,
 
     /// Custom agent servers configured by the user
     #[serde(flatten)]