Add version detection for CC (#36502)

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

Change summary

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(-)

Detailed changes

Cargo.lock πŸ”—

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

crates/acp_thread/src/acp_thread.rs πŸ”—

@@ -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));
     }
 }
 

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

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();
                 }
 

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<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>>,

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())
                 }

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<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();
 

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