Cargo.lock π
@@ -285,6 +285,7 @@ dependencies = [
"project",
"rand 0.8.5",
"schemars",
+ "semver",
"serde",
"serde_json",
"settings",
Cole Miller created
- Render a helpful message when the installed CC version is too old
- Show the full path for agent binaries when the version is not recent
enough (helps in cases where multiple binaries are installed in
different places)
- Add UI for the case where a server binary is not installed at all
- Refresh thread view after installing/updating server binary
Release Notes:
- N/A
Cargo.lock | 1
crates/acp_thread/src/acp_thread.rs | 22 ++
crates/agent_servers/Cargo.toml | 1
crates/agent_servers/src/acp/v1.rs | 6
crates/agent_servers/src/claude.rs | 57 ++++++++
crates/agent_servers/src/gemini.rs | 11 +
crates/agent_ui/src/acp/thread_view.rs | 180 +++++++++++++++++----------
crates/agent_ui/src/agent_diff.rs | 2
8 files changed, 195 insertions(+), 85 deletions(-)
@@ -285,6 +285,7 @@ dependencies = [
"project",
"rand 0.8.5",
"schemars",
+ "semver",
"serde",
"serde_json",
"settings",
@@ -707,7 +707,7 @@ pub enum AcpThreadEvent {
Retry(RetryStatus),
Stopped,
Error,
- ServerExited(ExitStatus),
+ LoadError(LoadError),
}
impl EventEmitter<AcpThreadEvent> for AcpThread {}
@@ -721,20 +721,30 @@ pub enum ThreadStatus {
#[derive(Debug, Clone)]
pub enum LoadError {
+ NotInstalled {
+ error_message: SharedString,
+ install_message: SharedString,
+ install_command: String,
+ },
Unsupported {
error_message: SharedString,
upgrade_message: SharedString,
upgrade_command: String,
},
- Exited(i32),
+ Exited {
+ status: ExitStatus,
+ },
Other(SharedString),
}
impl Display for LoadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
- LoadError::Unsupported { error_message, .. } => write!(f, "{}", error_message),
- LoadError::Exited(status) => write!(f, "Server exited with status {}", status),
+ LoadError::NotInstalled { error_message, .. }
+ | LoadError::Unsupported { error_message, .. } => {
+ write!(f, "{error_message}")
+ }
+ LoadError::Exited { status } => write!(f, "Server exited with status {status}"),
LoadError::Other(msg) => write!(f, "{}", msg),
}
}
@@ -1683,8 +1693,8 @@ impl AcpThread {
self.entries.iter().map(|e| e.to_markdown(cx)).collect()
}
- pub fn emit_server_exited(&mut self, status: ExitStatus, cx: &mut Context<Self>) {
- cx.emit(AcpThreadEvent::ServerExited(status));
+ pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context<Self>) {
+ cx.emit(AcpThreadEvent::LoadError(error));
}
}
@@ -37,6 +37,7 @@ paths.workspace = true
project.workspace = true
rand.workspace = true
schemars.workspace = true
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -14,7 +14,7 @@ use anyhow::{Context as _, Result};
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use crate::{AgentServerCommand, acp::UnsupportedVersion};
-use acp_thread::{AcpThread, AgentConnection, AuthRequired};
+use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError};
pub struct AcpConnection {
server_name: &'static str,
@@ -87,7 +87,9 @@ impl AcpConnection {
for session in sessions.borrow().values() {
session
.thread
- .update(cx, |thread, cx| thread.emit_server_exited(status, cx))
+ .update(cx, |thread, cx| {
+ thread.emit_load_error(LoadError::Exited { status }, cx)
+ })
.ok();
}
@@ -15,8 +15,9 @@ use smol::process::Child;
use std::any::Any;
use std::cell::RefCell;
use std::fmt::Display;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::rc::Rc;
+use util::command::new_smol_command;
use uuid::Uuid;
use agent_client_protocol as acp;
@@ -36,7 +37,7 @@ use util::{ResultExt, debug_panic};
use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
use crate::claude::tools::ClaudeTool;
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
-use acp_thread::{AcpThread, AgentConnection, AuthRequired, MentionUri};
+use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri};
#[derive(Clone)]
pub struct ClaudeCode;
@@ -103,7 +104,11 @@ impl AgentConnection for ClaudeAgentConnection {
)
.await
else {
- anyhow::bail!("Failed to find claude binary");
+ return Err(LoadError::NotInstalled {
+ error_message: "Failed to find Claude Code binary".into(),
+ install_message: "Install Claude Code".into(),
+ install_command: "npm install -g @anthropic-ai/claude-code@latest".into(),
+ }.into());
};
let api_key =
@@ -211,9 +216,32 @@ impl AgentConnection for ClaudeAgentConnection {
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| {
- thread.emit_server_exited(status, cx);
+ let error = if let Some(version) = version
+ && let Some(help) = help
+ && (!help.contains("--input-format")
+ || !help.contains("--session-id"))
+ {
+ LoadError::Unsupported {
+ error_message: format!(
+ "Your installed version of Claude Code ({}, version {}) does not have required features for use with Zed.",
+ command.path.to_string_lossy(),
+ version,
+ )
+ .into(),
+ upgrade_message: "Upgrade Claude Code to latest".into(),
+ upgrade_command: format!(
+ "{} update",
+ command.path.to_string_lossy()
+ ),
+ }
+ } else {
+ LoadError::Exited { status }
+ };
+ thread.emit_load_error(error, cx);
})
.ok();
}
@@ -383,6 +411,27 @@ fn spawn_claude(
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>>,
@@ -50,7 +50,11 @@ impl AgentServer for Gemini {
let Some(command) =
AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await
else {
- anyhow::bail!("Failed to find gemini binary");
+ return Err(LoadError::NotInstalled {
+ error_message: "Failed to find Gemini CLI binary".into(),
+ install_message: "Install Gemini CLI".into(),
+ install_command: "npm install -g @google/gemini-cli@latest".into()
+ }.into());
};
let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
@@ -75,10 +79,11 @@ impl AgentServer for Gemini {
if !supported {
return Err(LoadError::Unsupported {
error_message: format!(
- "Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
+ "Your installed version of Gemini CLI ({}, version {}) doesn't support the Agentic Coding Protocol (ACP).",
+ command.path.to_string_lossy(),
current_version
).into(),
- upgrade_message: "Upgrade Gemini to Latest".into(),
+ upgrade_message: "Upgrade Gemini CLI to latest".into(),
upgrade_command: "npm install -g @google/gemini-cli@latest".into(),
}.into())
}
@@ -37,7 +37,7 @@ use rope::Point;
use settings::{Settings as _, SettingsStore};
use std::sync::Arc;
use std::time::Instant;
-use std::{collections::BTreeMap, process::ExitStatus, rc::Rc, time::Duration};
+use std::{collections::BTreeMap, rc::Rc, time::Duration};
use text::Anchor;
use theme::ThemeSettings;
use ui::{
@@ -149,9 +149,6 @@ enum ThreadState {
configuration_view: Option<AnyView>,
_subscription: Option<Subscription>,
},
- ServerExited {
- status: ExitStatus,
- },
}
impl AcpThreadView {
@@ -451,8 +448,7 @@ impl AcpThreadView {
ThreadState::Ready { thread, .. } => Some(thread),
ThreadState::Unauthenticated { .. }
| ThreadState::Loading { .. }
- | ThreadState::LoadError(..)
- | ThreadState::ServerExited { .. } => None,
+ | ThreadState::LoadError { .. } => None,
}
}
@@ -462,7 +458,6 @@ impl AcpThreadView {
ThreadState::Loading { .. } => "Loadingβ¦".into(),
ThreadState::LoadError(_) => "Failed to load".into(),
ThreadState::Unauthenticated { .. } => "Authentication Required".into(),
- ThreadState::ServerExited { .. } => "Server exited unexpectedly".into(),
}
}
@@ -830,9 +825,9 @@ impl AcpThreadView {
cx,
);
}
- AcpThreadEvent::ServerExited(status) => {
+ AcpThreadEvent::LoadError(error) => {
self.thread_retry_status.take();
- self.thread_state = ThreadState::ServerExited { status: *status };
+ self.thread_state = ThreadState::LoadError(error.clone());
}
AcpThreadEvent::TitleUpdated | AcpThreadEvent::TokenUsageUpdated => {}
}
@@ -2154,28 +2149,6 @@ impl AcpThreadView {
))
}
- fn render_server_exited(&self, status: ExitStatus, _cx: &Context<Self>) -> AnyElement {
- v_flex()
- .items_center()
- .justify_center()
- .child(self.render_error_agent_logo())
- .child(
- v_flex()
- .mt_4()
- .mb_2()
- .gap_0p5()
- .text_center()
- .items_center()
- .child(Headline::new("Server exited unexpectedly").size(HeadlineSize::Medium))
- .child(
- Label::new(format!("Exit status: {}", status.code().unwrap_or(-127)))
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- .into_any_element()
- }
-
fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
let mut container = v_flex()
.items_center()
@@ -2204,39 +2177,102 @@ impl AcpThreadView {
{
let upgrade_message = upgrade_message.clone();
let upgrade_command = upgrade_command.clone();
- container = container.child(Button::new("upgrade", upgrade_message).on_click(
- cx.listener(move |this, _, window, cx| {
- this.workspace
- .update(cx, |workspace, cx| {
- let project = workspace.project().read(cx);
- let cwd = project.first_project_directory(cx);
- let shell = project.terminal_settings(&cwd, cx).shell.clone();
- let spawn_in_terminal = task::SpawnInTerminal {
- id: task::TaskId("install".to_string()),
- full_label: upgrade_command.clone(),
- label: upgrade_command.clone(),
- command: Some(upgrade_command.clone()),
- args: Vec::new(),
- command_label: upgrade_command.clone(),
- cwd,
- env: Default::default(),
- use_new_terminal: true,
- allow_concurrent_runs: true,
- reveal: Default::default(),
- reveal_target: Default::default(),
- hide: Default::default(),
- shell,
- show_summary: true,
- show_command: true,
- show_rerun: false,
- };
- workspace
- .spawn_in_terminal(spawn_in_terminal, window, cx)
- .detach();
+ container = container.child(
+ Button::new("upgrade", upgrade_message)
+ .tooltip(Tooltip::text(upgrade_command.clone()))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ let task = this
+ .workspace
+ .update(cx, |workspace, cx| {
+ let project = workspace.project().read(cx);
+ let cwd = project.first_project_directory(cx);
+ let shell = project.terminal_settings(&cwd, cx).shell.clone();
+ let spawn_in_terminal = task::SpawnInTerminal {
+ id: task::TaskId("upgrade".to_string()),
+ full_label: upgrade_command.clone(),
+ label: upgrade_command.clone(),
+ command: Some(upgrade_command.clone()),
+ args: Vec::new(),
+ command_label: upgrade_command.clone(),
+ cwd,
+ env: Default::default(),
+ use_new_terminal: true,
+ allow_concurrent_runs: true,
+ reveal: Default::default(),
+ reveal_target: Default::default(),
+ hide: Default::default(),
+ shell,
+ show_summary: true,
+ show_command: true,
+ show_rerun: false,
+ };
+ workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
+ })
+ .ok();
+ let Some(task) = task else { return };
+ cx.spawn_in(window, async move |this, cx| {
+ if let Some(Ok(_)) = task.await {
+ this.update_in(cx, |this, window, cx| {
+ this.reset(window, cx);
+ })
+ .ok();
+ }
})
- .ok();
- }),
- ));
+ .detach()
+ })),
+ );
+ } else if let LoadError::NotInstalled {
+ install_message,
+ install_command,
+ ..
+ } = e
+ {
+ let install_message = install_message.clone();
+ let install_command = install_command.clone();
+ container = container.child(
+ Button::new("install", install_message)
+ .tooltip(Tooltip::text(install_command.clone()))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ let task = this
+ .workspace
+ .update(cx, |workspace, cx| {
+ let project = workspace.project().read(cx);
+ let cwd = project.first_project_directory(cx);
+ let shell = project.terminal_settings(&cwd, cx).shell.clone();
+ let spawn_in_terminal = task::SpawnInTerminal {
+ id: task::TaskId("install".to_string()),
+ full_label: install_command.clone(),
+ label: install_command.clone(),
+ command: Some(install_command.clone()),
+ args: Vec::new(),
+ command_label: install_command.clone(),
+ cwd,
+ env: Default::default(),
+ use_new_terminal: true,
+ allow_concurrent_runs: true,
+ reveal: Default::default(),
+ reveal_target: Default::default(),
+ hide: Default::default(),
+ shell,
+ show_summary: true,
+ show_command: true,
+ show_rerun: false,
+ };
+ workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
+ })
+ .ok();
+ let Some(task) = task else { return };
+ cx.spawn_in(window, async move |this, cx| {
+ if let Some(Ok(_)) = task.await {
+ this.update_in(cx, |this, window, cx| {
+ this.reset(window, cx);
+ })
+ .ok();
+ }
+ })
+ .detach()
+ })),
+ );
}
container.into_any()
@@ -3705,6 +3741,18 @@ impl AcpThreadView {
}
}))
}
+
+ fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.thread_state = Self::initial_state(
+ self.agent.clone(),
+ None,
+ self.workspace.clone(),
+ self.project.clone(),
+ window,
+ cx,
+ );
+ cx.notify();
+ }
}
impl Focusable for AcpThreadView {
@@ -3743,12 +3791,6 @@ impl Render for AcpThreadView {
.items_center()
.justify_center()
.child(self.render_load_error(e, cx)),
- ThreadState::ServerExited { status } => v_flex()
- .p_2()
- .flex_1()
- .items_center()
- .justify_center()
- .child(self.render_server_exited(*status, cx)),
ThreadState::Ready { thread, .. } => {
let thread_clone = thread.clone();
@@ -1522,7 +1522,7 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
- AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::ServerExited(_) => {
+ AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
self.update_reviewing_editors(workspace, window, cx);
}
AcpThreadEvent::TitleUpdated