Detailed changes
@@ -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",
@@ -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
@@ -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() })
}
@@ -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(¶ms.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"),
- }
- }
-}
@@ -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))
- }
-}
@@ -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>>,
-}
@@ -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: (),
- })
- }
-}
@@ -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: (),
- })
- }
-}
@@ -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(())
- }
-}
@@ -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: (),
- })
- }
-}
@@ -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> {
@@ -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(),
},
@@ -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() {
@@ -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)]