From 3996587c0b05ec30c54491f6911edb24c01996a5 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 19 Aug 2025 21:59:14 -0400 Subject: [PATCH] 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 --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index 34a8ceac49..5dced73fb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,7 @@ dependencies = [ "project", "rand 0.8.5", "schemars", + "semver", "serde", "serde_json", "settings", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2be7ea7a12..5d3b35d018 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -707,7 +707,7 @@ pub enum AcpThreadEvent { Retry(RetryStatus), Stopped, Error, - ServerExited(ExitStatus), + LoadError(LoadError), } impl EventEmitter 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) { - cx.emit(AcpThreadEvent::ServerExited(status)); + pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context) { + cx.emit(AcpThreadEvent::LoadError(error)); } } diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 8cd6980ae1..b654486cb6 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -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 diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs index d749537c4c..e0e92f29ba 100644 --- a/crates/agent_servers/src/acp/v1.rs +++ b/crates/agent_servers/src/acp/v1.rs @@ -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(); } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index a53c81d4c4..3008edebeb 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -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> { + 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> { + 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, turn_state: Rc>, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 167e632d79..e1ecaf0bb5 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -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()) } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 5e5d4bb83c..4a9001b9f4 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -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, _subscription: Option, }, - 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) -> 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) -> 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.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(); diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index a695136562..b20b126d9b 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -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