Add version detection for CC (#36502)

- 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
This commit is contained in:
Cole Miller 2025-08-19 21:59:14 -04:00 committed by GitHub
parent 7c7043947b
commit 3996587c0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 195 additions and 85 deletions

1
Cargo.lock generated
View file

@ -285,6 +285,7 @@ dependencies = [
"project", "project",
"rand 0.8.5", "rand 0.8.5",
"schemars", "schemars",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"settings", "settings",

View file

@ -707,7 +707,7 @@ pub enum AcpThreadEvent {
Retry(RetryStatus), Retry(RetryStatus),
Stopped, Stopped,
Error, Error,
ServerExited(ExitStatus), LoadError(LoadError),
} }
impl EventEmitter<AcpThreadEvent> for AcpThread {} impl EventEmitter<AcpThreadEvent> for AcpThread {}
@ -721,20 +721,30 @@ pub enum ThreadStatus {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LoadError { pub enum LoadError {
NotInstalled {
error_message: SharedString,
install_message: SharedString,
install_command: String,
},
Unsupported { Unsupported {
error_message: SharedString, error_message: SharedString,
upgrade_message: SharedString, upgrade_message: SharedString,
upgrade_command: String, upgrade_command: String,
}, },
Exited(i32), Exited {
status: ExitStatus,
},
Other(SharedString), Other(SharedString),
} }
impl Display for LoadError { impl Display for LoadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
LoadError::Unsupported { error_message, .. } => write!(f, "{}", error_message), LoadError::NotInstalled { error_message, .. }
LoadError::Exited(status) => write!(f, "Server exited with status {}", status), | LoadError::Unsupported { error_message, .. } => {
write!(f, "{error_message}")
}
LoadError::Exited { status } => write!(f, "Server exited with status {status}"),
LoadError::Other(msg) => write!(f, "{}", msg), LoadError::Other(msg) => write!(f, "{}", msg),
} }
} }
@ -1683,8 +1693,8 @@ impl AcpThread {
self.entries.iter().map(|e| e.to_markdown(cx)).collect() self.entries.iter().map(|e| e.to_markdown(cx)).collect()
} }
pub fn emit_server_exited(&mut self, status: ExitStatus, cx: &mut Context<Self>) { pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context<Self>) {
cx.emit(AcpThreadEvent::ServerExited(status)); cx.emit(AcpThreadEvent::LoadError(error));
} }
} }

View file

@ -37,6 +37,7 @@ paths.workspace = true
project.workspace = true project.workspace = true
rand.workspace = true rand.workspace = true
schemars.workspace = true schemars.workspace = true
semver.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
settings.workspace = true settings.workspace = true

View file

@ -14,7 +14,7 @@ use anyhow::{Context as _, Result};
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use crate::{AgentServerCommand, acp::UnsupportedVersion}; use crate::{AgentServerCommand, acp::UnsupportedVersion};
use acp_thread::{AcpThread, AgentConnection, AuthRequired}; use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError};
pub struct AcpConnection { pub struct AcpConnection {
server_name: &'static str, server_name: &'static str,
@ -87,7 +87,9 @@ impl AcpConnection {
for session in sessions.borrow().values() { for session in sessions.borrow().values() {
session session
.thread .thread
.update(cx, |thread, cx| thread.emit_server_exited(status, cx)) .update(cx, |thread, cx| {
thread.emit_load_error(LoadError::Exited { status }, cx)
})
.ok(); .ok();
} }

View file

@ -15,8 +15,9 @@ use smol::process::Child;
use std::any::Any; use std::any::Any;
use std::cell::RefCell; use std::cell::RefCell;
use std::fmt::Display; use std::fmt::Display;
use std::path::Path; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use util::command::new_smol_command;
use uuid::Uuid; use uuid::Uuid;
use agent_client_protocol as acp; 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::mcp_server::{ClaudeZedMcpServer, McpConfig};
use crate::claude::tools::ClaudeTool; use crate::claude::tools::ClaudeTool;
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings}; use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
use acp_thread::{AcpThread, AgentConnection, AuthRequired, MentionUri}; use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri};
#[derive(Clone)] #[derive(Clone)]
pub struct ClaudeCode; pub struct ClaudeCode;
@ -103,7 +104,11 @@ impl AgentConnection for ClaudeAgentConnection {
) )
.await .await
else { 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 = let api_key =
@ -211,9 +216,32 @@ impl AgentConnection for ClaudeAgentConnection {
if let Some(status) = child.status().await.log_err() if let Some(status) = child.status().await.log_err()
&& let Some(thread) = thread_rx.recv().await.ok() && 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 thread
.update(cx, |thread, cx| { .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(); .ok();
} }
@ -383,6 +411,27 @@ fn spawn_claude(
Ok(child) 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 { struct ClaudeAgentSession {
outgoing_tx: UnboundedSender<SdkMessage>, outgoing_tx: UnboundedSender<SdkMessage>,
turn_state: Rc<RefCell<TurnState>>, turn_state: Rc<RefCell<TurnState>>,

View file

@ -50,7 +50,11 @@ impl AgentServer for Gemini {
let Some(command) = let Some(command) =
AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await
else { 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; let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
@ -75,10 +79,11 @@ impl AgentServer for Gemini {
if !supported { if !supported {
return Err(LoadError::Unsupported { return Err(LoadError::Unsupported {
error_message: format!( 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 current_version
).into(), ).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(), upgrade_command: "npm install -g @google/gemini-cli@latest".into(),
}.into()) }.into())
} }

View file

@ -37,7 +37,7 @@ use rope::Point;
use settings::{Settings as _, SettingsStore}; use settings::{Settings as _, SettingsStore};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; 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 text::Anchor;
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{ use ui::{
@ -149,9 +149,6 @@ enum ThreadState {
configuration_view: Option<AnyView>, configuration_view: Option<AnyView>,
_subscription: Option<Subscription>, _subscription: Option<Subscription>,
}, },
ServerExited {
status: ExitStatus,
},
} }
impl AcpThreadView { impl AcpThreadView {
@ -451,8 +448,7 @@ impl AcpThreadView {
ThreadState::Ready { thread, .. } => Some(thread), ThreadState::Ready { thread, .. } => Some(thread),
ThreadState::Unauthenticated { .. } ThreadState::Unauthenticated { .. }
| ThreadState::Loading { .. } | ThreadState::Loading { .. }
| ThreadState::LoadError(..) | ThreadState::LoadError { .. } => None,
| ThreadState::ServerExited { .. } => None,
} }
} }
@ -462,7 +458,6 @@ impl AcpThreadView {
ThreadState::Loading { .. } => "Loading…".into(), ThreadState::Loading { .. } => "Loading…".into(),
ThreadState::LoadError(_) => "Failed to load".into(), ThreadState::LoadError(_) => "Failed to load".into(),
ThreadState::Unauthenticated { .. } => "Authentication Required".into(), ThreadState::Unauthenticated { .. } => "Authentication Required".into(),
ThreadState::ServerExited { .. } => "Server exited unexpectedly".into(),
} }
} }
@ -830,9 +825,9 @@ impl AcpThreadView {
cx, cx,
); );
} }
AcpThreadEvent::ServerExited(status) => { AcpThreadEvent::LoadError(error) => {
self.thread_retry_status.take(); self.thread_retry_status.take();
self.thread_state = ThreadState::ServerExited { status: *status }; self.thread_state = ThreadState::LoadError(error.clone());
} }
AcpThreadEvent::TitleUpdated | AcpThreadEvent::TokenUsageUpdated => {} 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 { fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
let mut container = v_flex() let mut container = v_flex()
.items_center() .items_center()
@ -2204,39 +2177,102 @@ impl AcpThreadView {
{ {
let upgrade_message = upgrade_message.clone(); let upgrade_message = upgrade_message.clone();
let upgrade_command = upgrade_command.clone(); let upgrade_command = upgrade_command.clone();
container = container.child(Button::new("upgrade", upgrade_message).on_click( container = container.child(
cx.listener(move |this, _, window, cx| { Button::new("upgrade", upgrade_message)
this.workspace .tooltip(Tooltip::text(upgrade_command.clone()))
.update(cx, |workspace, cx| { .on_click(cx.listener(move |this, _, window, cx| {
let project = workspace.project().read(cx); let task = this
let cwd = project.first_project_directory(cx); .workspace
let shell = project.terminal_settings(&cwd, cx).shell.clone(); .update(cx, |workspace, cx| {
let spawn_in_terminal = task::SpawnInTerminal { let project = workspace.project().read(cx);
id: task::TaskId("install".to_string()), let cwd = project.first_project_directory(cx);
full_label: upgrade_command.clone(), let shell = project.terminal_settings(&cwd, cx).shell.clone();
label: upgrade_command.clone(), let spawn_in_terminal = task::SpawnInTerminal {
command: Some(upgrade_command.clone()), id: task::TaskId("upgrade".to_string()),
args: Vec::new(), full_label: upgrade_command.clone(),
command_label: upgrade_command.clone(), label: upgrade_command.clone(),
cwd, command: Some(upgrade_command.clone()),
env: Default::default(), args: Vec::new(),
use_new_terminal: true, command_label: upgrade_command.clone(),
allow_concurrent_runs: true, cwd,
reveal: Default::default(), env: Default::default(),
reveal_target: Default::default(), use_new_terminal: true,
hide: Default::default(), allow_concurrent_runs: true,
shell, reveal: Default::default(),
show_summary: true, reveal_target: Default::default(),
show_command: true, hide: Default::default(),
show_rerun: false, shell,
}; show_summary: true,
workspace show_command: true,
.spawn_in_terminal(spawn_in_terminal, window, cx) show_rerun: false,
.detach(); };
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() 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 { impl Focusable for AcpThreadView {
@ -3743,12 +3791,6 @@ impl Render for AcpThreadView {
.items_center() .items_center()
.justify_center() .justify_center()
.child(self.render_load_error(e, cx)), .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, .. } => { ThreadState::Ready { thread, .. } => {
let thread_clone = thread.clone(); let thread_clone = thread.clone();

View file

@ -1522,7 +1522,7 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx); 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); self.update_reviewing_editors(workspace, window, cx);
} }
AcpThreadEvent::TitleUpdated AcpThreadEvent::TitleUpdated