ACP (#34030)

Conrad Irwin created

Implements an ACP client that can be used from the agent panel

Change summary

Cargo.lock                                     |   68 
Cargo.toml                                     |    7 
assets/icons/ai_gemini.svg                     |    1 
assets/icons/tool_bulb.svg                     |    3 
assets/icons/tool_folder.svg                   |    3 
assets/icons/tool_hammer.svg                   |    5 
assets/icons/tool_pencil.svg                   |    4 
assets/icons/tool_regex.svg                    |    4 
assets/icons/tool_search.svg                   |    4 
assets/icons/tool_terminal.svg                 |    5 
assets/icons/tool_web.svg                      |   17 
assets/keymaps/default-linux.json              |    9 
assets/keymaps/default-macos.json              |    9 
assets/settings/default.json                   |    2 
crates/acp/Cargo.toml                          |   46 
crates/acp/LICENSE-GPL                         |    1 
crates/acp/src/acp.rs                          | 1625 ++++++++++++++++
crates/agent_servers/Cargo.toml                |   27 
crates/agent_servers/LICENSE-GPL               |    1 
crates/agent_servers/src/agent_servers.rs      |  231 ++
crates/agent_ui/Cargo.toml                     |    9 
crates/agent_ui/src/acp.rs                     |    5 
crates/agent_ui/src/acp/completion_provider.rs |  574 +++++
crates/agent_ui/src/acp/message_history.rs     |   81 
crates/agent_ui/src/acp/thread_view.rs         | 1972 ++++++++++++++++++++
crates/agent_ui/src/agent_panel.rs             |   94 
crates/agent_ui/src/agent_ui.rs                |    5 
crates/agent_ui/src/context_picker.rs          |    2 
crates/agent_ui/src/message_editor.rs          |    3 
crates/feature_flags/src/feature_flags.rs      |    6 
crates/icons/src/icons.rs                      |    9 
crates/paths/src/paths.rs                      |    8 
crates/project/src/environment.rs              |    4 
crates/ui/src/traits/styled_ext.rs             |    2 
crates/vim/src/test/vim_test_context.rs        |    4 
crates/zed/Cargo.toml                          |    1 
crates/zed/src/main.rs                         |    1 
crates/zed_actions/src/lib.rs                  |    8 
tooling/workspace-hack/Cargo.toml              |    2 
39 files changed, 4,839 insertions(+), 23 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -2,6 +2,33 @@
 # It is not intended for manual editing.
 version = 4
 
+[[package]]
+name = "acp"
+version = "0.1.0"
+dependencies = [
+ "agent_servers",
+ "agentic-coding-protocol",
+ "anyhow",
+ "async-pipe",
+ "buffer_diff",
+ "editor",
+ "env_logger 0.11.8",
+ "futures 0.3.31",
+ "gpui",
+ "indoc",
+ "itertools 0.14.0",
+ "language",
+ "markdown",
+ "project",
+ "serde_json",
+ "settings",
+ "smol",
+ "tempfile",
+ "ui",
+ "util",
+ "workspace-hack",
+]
+
 [[package]]
 name = "activity_indicator"
 version = "0.1.0"
@@ -107,6 +134,24 @@ dependencies = [
  "zstd",
 ]
 
+[[package]]
+name = "agent_servers"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "futures 0.3.31",
+ "gpui",
+ "paths",
+ "project",
+ "schemars",
+ "serde",
+ "settings",
+ "util",
+ "which 6.0.3",
+ "workspace-hack",
+]
+
 [[package]]
 name = "agent_settings"
 version = "0.1.0"
@@ -130,8 +175,11 @@ dependencies = [
 name = "agent_ui"
 version = "0.1.0"
 dependencies = [
+ "acp",
  "agent",
+ "agent_servers",
  "agent_settings",
+ "agentic-coding-protocol",
  "anyhow",
  "assistant_context",
  "assistant_slash_command",
@@ -191,6 +239,7 @@ dependencies = [
  "settings",
  "smol",
  "streaming_diff",
+ "task",
  "telemetry",
  "telemetry_events",
  "terminal",
@@ -212,6 +261,22 @@ dependencies = [
  "zed_llm_client",
 ]
 
+[[package]]
+name = "agentic-coding-protocol"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b962eee17ee3924870d9b9d28cc8b6dcb5421e4d4e81cd864226374a122ceed1"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "futures 0.3.31",
+ "log",
+ "parking_lot",
+ "schemars",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "ahash"
 version = "0.7.8"
@@ -14078,6 +14143,7 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984"
 dependencies = [
+ "chrono",
  "dyn-clone",
  "indexmap",
  "ref-cast",
@@ -19579,6 +19645,7 @@ dependencies = [
  "rustix 1.0.7",
  "rustls 0.23.26",
  "rustls-webpki 0.103.1",
+ "schemars",
  "scopeguard",
  "sea-orm",
  "sea-query-binder",
@@ -19976,6 +20043,7 @@ version = "0.196.0"
 dependencies = [
  "activity_indicator",
  "agent",
+ "agent_servers",
  "agent_settings",
  "agent_ui",
  "anyhow",

Cargo.toml πŸ”—

@@ -2,9 +2,11 @@
 resolver = "2"
 members = [
     "crates/activity_indicator",
+    "crates/acp",
     "crates/agent_ui",
     "crates/agent",
     "crates/agent_settings",
+    "crates/agent_servers",
     "crates/anthropic",
     "crates/askpass",
     "crates/assets",
@@ -216,10 +218,12 @@ edition = "2024"
 # Workspace member crates
 #
 
-activity_indicator = { path = "crates/activity_indicator" }
+acp = { path = "crates/acp" }
 agent = { path = "crates/agent" }
+activity_indicator = { path = "crates/activity_indicator" }
 agent_ui = { path = "crates/agent_ui" }
 agent_settings = { path = "crates/agent_settings" }
+agent_servers = { path = "crates/agent_servers" }
 ai = { path = "crates/ai" }
 anthropic = { path = "crates/anthropic" }
 askpass = { path = "crates/askpass" }
@@ -400,6 +404,7 @@ zlog_settings = { path = "crates/zlog_settings" }
 # External crates
 #
 
+agentic-coding-protocol = "0.0.5"
 aho-corasick = "1.1"
 alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
 any_vec = "0.14"

assets/icons/ai_gemini.svg πŸ”—

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>

assets/icons/tool_bulb.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.4174 10.2159C10.5454 9.58974 10.4174 9.57261 11.3762 8.46959C11.9337 7.82822 12.335 7.09214 12.335 6.27818C12.335 5.28184 11.9309 4.32631 11.2118 3.62179C10.4926 2.91728 9.5171 2.52148 8.50001 2.52148C7.48291 2.52148 6.50748 2.91728 5.78828 3.62179C5.06909 4.32631 4.66504 5.28184 4.66504 6.27818C4.66504 6.9043 4.79288 7.65565 5.62379 8.46959C6.58253 9.59098 6.45474 9.58974 6.58257 10.2159M10.4174 10.2159L10.4174 12.2989C10.4174 12.9504 9.87836 13.4786 9.21329 13.4786H7.78674C7.12167 13.4786 6.58253 12.9504 6.58253 12.2989L6.58257 10.2159M10.4174 10.2159H8.50001H6.58257" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/tool_folder.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.4 12.5C12.6917 12.5 12.9715 12.3884 13.1778 12.1899C13.3841 11.9913 13.5 11.722 13.5 11.4412V6.14706C13.5 5.86624 13.3841 5.59693 13.1778 5.39836C12.9715 5.19979 12.6917 5.08824 12.4 5.08824H8.055C7.87103 5.08997 7.68955 5.04726 7.52717 4.96402C7.36478 4.88078 7.22668 4.75967 7.1255 4.61176L6.68 3.97647C6.57984 3.83007 6.44349 3.7099 6.28317 3.62674C6.12286 3.54358 5.94361 3.50003 5.7615 3.5H3.6C3.30826 3.5 3.02847 3.61155 2.82218 3.81012C2.61589 4.00869 2.5 4.27801 2.5 4.55882V11.4412C2.5 11.722 2.61589 11.9913 2.82218 12.1899C3.02847 12.3884 3.30826 12.5 3.6 12.5H12.4Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/tool_hammer.svg πŸ”—

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9 8.5L4.94864 12.6222C4.71647 12.8544 4.40157 12.9848 4.07323 12.9848C3.74488 12.9848 3.42999 12.8544 3.19781 12.6222C2.96564 12.39 2.83521 12.0751 2.83521 11.7468C2.83521 11.4185 2.96564 11.1036 3.19781 10.8714L7.5 6.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.8352 9.98474L13.8352 6.98474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.8352 7.42495L11.7634 6.4298C11.5533 6.23484 11.4353 5.97039 11.4352 5.69462V5.08526L10.1696 3.91022C9.54495 3.33059 8.69961 3.00261 7.81649 2.99722L5.83521 2.98474L6.35041 3.41108C6.71634 3.71233 7.00935 4.08216 7.21013 4.4962C7.4109 4.91024 7.51488 5.35909 7.51521 5.81316L7.5 6.5L9 8.5L9.5 8C9.5 8 9.87337 7.79457 10.0834 7.98959L11.1552 8.98474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/tool_pencil.svg πŸ”—

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5871 5.40582C12.8514 5.14152 13 4.78304 13 4.40922C13 4.03541 12.8516 3.67688 12.5873 3.41252C12.323 3.14816 11.9645 2.99962 11.5907 2.99957C11.2169 2.99953 10.8584 3.14798 10.594 3.41227L3.92098 10.0869C3.80488 10.2027 3.71903 10.3452 3.67097 10.5019L3.01047 12.678C2.99754 12.7212 2.99657 12.7672 3.00764 12.8109C3.01872 12.8547 3.04143 12.8946 3.07337 12.9265C3.1053 12.9584 3.14528 12.981 3.18905 12.992C3.23282 13.003 3.27875 13.002 3.32197 12.989L5.49849 12.329C5.65508 12.2813 5.79758 12.196 5.91349 12.0805L12.5871 5.40582Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 5L11 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/tool_regex.svg πŸ”—

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.57132 13.7143C5.20251 13.7143 5.71418 13.2026 5.71418 12.5714C5.71418 11.9403 5.20251 11.4286 4.57132 11.4286C3.94014 11.4286 3.42847 11.9403 3.42847 12.5714C3.42847 13.2026 3.94014 13.7143 4.57132 13.7143Z" fill="black"/>
+<path d="M10.2856 2.85712V5.71426M10.2856 5.71426V8.5714M10.2856 5.71426H13.1428M10.2856 5.71426H7.42847M10.2856 5.71426L12.1904 3.80949M10.2856 5.71426L8.38084 7.61906M10.2856 5.71426L12.1904 7.61906M10.2856 5.71426L8.38084 3.80949" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
+</svg>

assets/icons/tool_search.svg πŸ”—

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13 13L11 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 12C9.98528 12 12 9.98528 12 7.5C12 5.01472 9.98528 3 7.5 3C5.01472 3 3 5.01472 3 7.5C3 9.98528 5.01472 12 7.5 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/tool_terminal.svg πŸ”—

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.99487 8.44023L7.32821 7.10689L5.99487 5.77356" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.33838 10.2264H10.005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.8889 3H4.11111C3.49746 3 3 3.49746 3 4.11111V11.8889C3 12.5025 3.49746 13 4.11111 13H11.8889C12.5025 13 13 12.5025 13 11.8889V4.11111C13 3.49746 12.5025 3 11.8889 3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/tool_web.svg πŸ”—

@@ -0,0 +1,17 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2663_433)">
+<mask id="mask0_2663_433" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
+<path d="M16 0H0V16H16V0Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_2663_433)">
+<path d="M8 13C10.7614 13 13 10.7614 13 7.99999C13 5.23857 10.7614 3 8 3C5.23857 3 3 5.23857 3 7.99999C3 10.7614 5.23857 13 8 13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 3C6.71611 4.34807 6 6.13836 6 7.99999C6 9.86163 6.71611 11.6519 8 13C9.28387 11.6519 10 9.86163 10 7.99999C10 6.13836 9.28387 4.34807 8 3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 8H13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</g>
+<defs>
+<clipPath id="clip0_2663_433">
+<rect width="16" height="16" fill="white"/>
+</clipPath>
+</defs>
+</svg>

assets/keymaps/default-linux.json πŸ”—

@@ -306,6 +306,15 @@
       "enter": "agent::AcceptSuggestedContext"
     }
   },
+  {
+    "context": "AcpThread > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "agent::Chat",
+      "up": "agent::PreviousHistoryMessage",
+      "down": "agent::NextHistoryMessage"
+    }
+  },
   {
     "context": "ThreadHistory",
     "bindings": {

assets/keymaps/default-macos.json πŸ”—

@@ -357,6 +357,15 @@
       "ctrl--": "pane::GoBack"
     }
   },
+  {
+    "context": "AcpThread > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "agent::Chat",
+      "up": "agent::PreviousHistoryMessage",
+      "down": "agent::NextHistoryMessage"
+    }
+  },
   {
     "context": "ThreadHistory",
     "bindings": {

assets/settings/default.json πŸ”—

@@ -1855,6 +1855,8 @@
   "read_ssh_config": true,
   // Configures context servers for use by the agent.
   "context_servers": {},
+  // Configures agent servers available in the agent panel.
+  "agent_servers": {},
   "debugger": {
     "stepping_granularity": "line",
     "save_breakpoints": true,

crates/acp/Cargo.toml πŸ”—

@@ -0,0 +1,46 @@
+[package]
+name = "acp"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/acp.rs"
+doctest = false
+
+[features]
+test-support = ["gpui/test-support", "project/test-support"]
+gemini = []
+
+[dependencies]
+agent_servers.workspace = true
+agentic-coding-protocol.workspace = true
+anyhow.workspace = true
+buffer_diff.workspace = true
+editor.workspace = true
+futures.workspace = true
+gpui.workspace = true
+itertools.workspace = true
+language.workspace = true
+markdown.workspace = true
+project.workspace = true
+settings.workspace = true
+smol.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace-hack.workspace = true
+
+[dev-dependencies]
+async-pipe.workspace = true
+env_logger.workspace = true
+gpui = { workspace = true, "features" = ["test-support"] }
+indoc.workspace = true
+project = { workspace = true, "features" = ["test-support"] }
+serde_json.workspace = true
+tempfile.workspace = true
+util.workspace = true
+settings.workspace = true

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

@@ -0,0 +1,1625 @@
+pub use acp::ToolCallId;
+use agent_servers::AgentServer;
+use agentic_coding_protocol::{self as acp, UserMessageChunk};
+use anyhow::{Context as _, Result, anyhow};
+use buffer_diff::BufferDiff;
+use editor::{MultiBuffer, PathKey};
+use futures::{FutureExt, channel::oneshot, future::BoxFuture};
+use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
+use itertools::Itertools;
+use language::{Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _};
+use markdown::Markdown;
+use project::Project;
+use std::error::Error;
+use std::fmt::{Formatter, Write};
+use std::{
+    fmt::Display,
+    mem,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+use ui::{App, IconName};
+use util::ResultExt;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UserMessage {
+    pub content: Entity<Markdown>,
+}
+
+impl UserMessage {
+    pub fn from_acp(
+        message: acp::UserMessage,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> Self {
+        let mut md_source = String::new();
+
+        for chunk in message.chunks {
+            match chunk {
+                UserMessageChunk::Text { chunk } => md_source.push_str(&chunk),
+                UserMessageChunk::Path { path } => {
+                    write!(&mut md_source, "{}", MentionPath(&path)).unwrap()
+                }
+            }
+        }
+
+        Self {
+            content: cx
+                .new(|cx| Markdown::new(md_source.into(), Some(language_registry), None, cx)),
+        }
+    }
+
+    fn to_markdown(&self, cx: &App) -> String {
+        format!("## User\n\n{}\n\n", self.content.read(cx).source())
+    }
+}
+
+#[derive(Debug)]
+pub struct MentionPath<'a>(&'a Path);
+
+impl<'a> MentionPath<'a> {
+    const PREFIX: &'static str = "@file:";
+
+    pub fn new(path: &'a Path) -> Self {
+        MentionPath(path)
+    }
+
+    pub fn try_parse(url: &'a str) -> Option<Self> {
+        let path = url.strip_prefix(Self::PREFIX)?;
+        Some(MentionPath(Path::new(path)))
+    }
+
+    pub fn path(&self) -> &Path {
+        self.0
+    }
+}
+
+impl Display for MentionPath<'_> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "[@{}]({}{})",
+            self.0.file_name().unwrap_or_default().display(),
+            Self::PREFIX,
+            self.0.display()
+        )
+    }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AssistantMessage {
+    pub chunks: Vec<AssistantMessageChunk>,
+}
+
+impl AssistantMessage {
+    fn to_markdown(&self, cx: &App) -> String {
+        format!(
+            "## Assistant\n\n{}\n\n",
+            self.chunks
+                .iter()
+                .map(|chunk| chunk.to_markdown(cx))
+                .join("\n\n")
+        )
+    }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AssistantMessageChunk {
+    Text { chunk: Entity<Markdown> },
+    Thought { chunk: Entity<Markdown> },
+}
+
+impl AssistantMessageChunk {
+    pub fn from_acp(
+        chunk: acp::AssistantMessageChunk,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> Self {
+        match chunk {
+            acp::AssistantMessageChunk::Text { chunk } => Self::Text {
+                chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
+            },
+            acp::AssistantMessageChunk::Thought { chunk } => Self::Thought {
+                chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
+            },
+        }
+    }
+
+    pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
+        Self::Text {
+            chunk: cx.new(|cx| {
+                Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
+            }),
+        }
+    }
+
+    fn to_markdown(&self, cx: &App) -> String {
+        match self {
+            Self::Text { chunk } => chunk.read(cx).source().to_string(),
+            Self::Thought { chunk } => {
+                format!("<thinking>\n{}\n</thinking>", chunk.read(cx).source())
+            }
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum AgentThreadEntry {
+    UserMessage(UserMessage),
+    AssistantMessage(AssistantMessage),
+    ToolCall(ToolCall),
+}
+
+impl AgentThreadEntry {
+    fn to_markdown(&self, cx: &App) -> String {
+        match self {
+            Self::UserMessage(message) => message.to_markdown(cx),
+            Self::AssistantMessage(message) => message.to_markdown(cx),
+            Self::ToolCall(too_call) => too_call.to_markdown(cx),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub struct ToolCall {
+    pub id: acp::ToolCallId,
+    pub label: Entity<Markdown>,
+    pub icon: IconName,
+    pub content: Option<ToolCallContent>,
+    pub status: ToolCallStatus,
+}
+
+impl ToolCall {
+    fn to_markdown(&self, cx: &App) -> String {
+        let mut markdown = format!(
+            "**Tool Call: {}**\nStatus: {}\n\n",
+            self.label.read(cx).source(),
+            self.status
+        );
+        if let Some(content) = &self.content {
+            markdown.push_str(content.to_markdown(cx).as_str());
+            markdown.push_str("\n\n");
+        }
+        markdown
+    }
+}
+
+#[derive(Debug)]
+pub enum ToolCallStatus {
+    WaitingForConfirmation {
+        confirmation: ToolCallConfirmation,
+        respond_tx: oneshot::Sender<acp::ToolCallConfirmationOutcome>,
+    },
+    Allowed {
+        status: acp::ToolCallStatus,
+    },
+    Rejected,
+    Canceled,
+}
+
+impl Display for ToolCallStatus {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation",
+                ToolCallStatus::Allowed { status } => match status {
+                    acp::ToolCallStatus::Running => "Running",
+                    acp::ToolCallStatus::Finished => "Finished",
+                    acp::ToolCallStatus::Error => "Error",
+                },
+                ToolCallStatus::Rejected => "Rejected",
+                ToolCallStatus::Canceled => "Canceled",
+            }
+        )
+    }
+}
+
+#[derive(Debug)]
+pub enum ToolCallConfirmation {
+    Edit {
+        description: Option<Entity<Markdown>>,
+    },
+    Execute {
+        command: String,
+        root_command: String,
+        description: Option<Entity<Markdown>>,
+    },
+    Mcp {
+        server_name: String,
+        tool_name: String,
+        tool_display_name: String,
+        description: Option<Entity<Markdown>>,
+    },
+    Fetch {
+        urls: Vec<SharedString>,
+        description: Option<Entity<Markdown>>,
+    },
+    Other {
+        description: Entity<Markdown>,
+    },
+}
+
+impl ToolCallConfirmation {
+    pub fn from_acp(
+        confirmation: acp::ToolCallConfirmation,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> Self {
+        let to_md = |description: String, cx: &mut App| -> Entity<Markdown> {
+            cx.new(|cx| {
+                Markdown::new(
+                    description.into(),
+                    Some(language_registry.clone()),
+                    None,
+                    cx,
+                )
+            })
+        };
+
+        match confirmation {
+            acp::ToolCallConfirmation::Edit { description } => Self::Edit {
+                description: description.map(|description| to_md(description, cx)),
+            },
+            acp::ToolCallConfirmation::Execute {
+                command,
+                root_command,
+                description,
+            } => Self::Execute {
+                command,
+                root_command,
+                description: description.map(|description| to_md(description, cx)),
+            },
+            acp::ToolCallConfirmation::Mcp {
+                server_name,
+                tool_name,
+                tool_display_name,
+                description,
+            } => Self::Mcp {
+                server_name,
+                tool_name,
+                tool_display_name,
+                description: description.map(|description| to_md(description, cx)),
+            },
+            acp::ToolCallConfirmation::Fetch { urls, description } => Self::Fetch {
+                urls: urls.iter().map(|url| url.into()).collect(),
+                description: description.map(|description| to_md(description, cx)),
+            },
+            acp::ToolCallConfirmation::Other { description } => Self::Other {
+                description: to_md(description, cx),
+            },
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum ToolCallContent {
+    Markdown { markdown: Entity<Markdown> },
+    Diff { diff: Diff },
+}
+
+impl ToolCallContent {
+    pub fn from_acp(
+        content: acp::ToolCallContent,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> Self {
+        match content {
+            acp::ToolCallContent::Markdown { markdown } => Self::Markdown {
+                markdown: cx.new(|cx| Markdown::new_text(markdown.into(), cx)),
+            },
+            acp::ToolCallContent::Diff { diff } => Self::Diff {
+                diff: Diff::from_acp(diff, language_registry, cx),
+            },
+        }
+    }
+
+    fn to_markdown(&self, cx: &App) -> String {
+        match self {
+            Self::Markdown { markdown } => markdown.read(cx).source().to_string(),
+            Self::Diff { diff } => diff.to_markdown(cx),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub struct Diff {
+    pub multibuffer: Entity<MultiBuffer>,
+    pub path: PathBuf,
+    _task: Task<Result<()>>,
+}
+
+impl Diff {
+    pub fn from_acp(
+        diff: acp::Diff,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> Self {
+        let acp::Diff {
+            path,
+            old_text,
+            new_text,
+        } = diff;
+
+        let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly));
+
+        let new_buffer = cx.new(|cx| Buffer::local(new_text, cx));
+        let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx));
+        let new_buffer_snapshot = new_buffer.read(cx).text_snapshot();
+        let old_buffer_snapshot = old_buffer.read(cx).snapshot();
+        let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx));
+        let diff_task = buffer_diff.update(cx, |diff, cx| {
+            diff.set_base_text(
+                old_buffer_snapshot,
+                Some(language_registry.clone()),
+                new_buffer_snapshot,
+                cx,
+            )
+        });
+
+        let task = cx.spawn({
+            let multibuffer = multibuffer.clone();
+            let path = path.clone();
+            async move |cx| {
+                diff_task.await?;
+
+                multibuffer
+                    .update(cx, |multibuffer, cx| {
+                        let hunk_ranges = {
+                            let buffer = new_buffer.read(cx);
+                            let diff = buffer_diff.read(cx);
+                            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
+                                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
+                                .collect::<Vec<_>>()
+                        };
+
+                        multibuffer.set_excerpts_for_path(
+                            PathKey::for_buffer(&new_buffer, cx),
+                            new_buffer.clone(),
+                            hunk_ranges,
+                            editor::DEFAULT_MULTIBUFFER_CONTEXT,
+                            cx,
+                        );
+                        multibuffer.add_diff(buffer_diff.clone(), cx);
+                    })
+                    .log_err();
+
+                if let Some(language) = language_registry
+                    .language_for_file_path(&path)
+                    .await
+                    .log_err()
+                {
+                    new_buffer.update(cx, |buffer, cx| buffer.set_language(Some(language), cx))?;
+                }
+
+                anyhow::Ok(())
+            }
+        });
+
+        Self {
+            multibuffer,
+            path,
+            _task: task,
+        }
+    }
+
+    fn to_markdown(&self, cx: &App) -> String {
+        let buffer_text = self
+            .multibuffer
+            .read(cx)
+            .all_buffers()
+            .iter()
+            .map(|buffer| buffer.read(cx).text())
+            .join("\n");
+        format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text)
+    }
+}
+
+pub struct AcpThread {
+    entries: Vec<AgentThreadEntry>,
+    title: SharedString,
+    project: Entity<Project>,
+    send_task: Option<Task<()>>,
+    connection: Arc<acp::AgentConnection>,
+    child_status: Option<Task<Result<()>>>,
+    _io_task: Task<()>,
+}
+
+pub enum AcpThreadEvent {
+    NewEntry,
+    EntryUpdated(usize),
+}
+
+impl EventEmitter<AcpThreadEvent> for AcpThread {}
+
+#[derive(PartialEq, Eq)]
+pub enum ThreadStatus {
+    Idle,
+    WaitingForToolConfirmation,
+    Generating,
+}
+
+#[derive(Debug, Clone)]
+pub enum LoadError {
+    Unsupported { current_version: SharedString },
+    Exited(i32),
+    Other(SharedString),
+}
+
+impl Display for LoadError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        match self {
+            LoadError::Unsupported { current_version } => {
+                write!(
+                    f,
+                    "Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
+                    current_version
+                )
+            }
+            LoadError::Exited(status) => write!(f, "Server exited with status {}", status),
+            LoadError::Other(msg) => write!(f, "{}", msg),
+        }
+    }
+}
+
+impl Error for LoadError {}
+
+impl AcpThread {
+    pub async fn spawn(
+        server: impl AgentServer + 'static,
+        root_dir: &Path,
+        project: Entity<Project>,
+        cx: &mut AsyncApp,
+    ) -> Result<Entity<Self>> {
+        let command = match server.command(&project, cx).await {
+            Ok(command) => command,
+            Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))),
+        };
+
+        let mut child = util::command::new_smol_command(&command.path)
+            .args(command.args.iter())
+            .current_dir(root_dir)
+            .stdin(std::process::Stdio::piped())
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::inherit())
+            .kill_on_drop(true)
+            .spawn()?;
+
+        let stdin = child.stdin.take().unwrap();
+        let stdout = child.stdout.take().unwrap();
+
+        cx.new(|cx| {
+            let foreground_executor = cx.foreground_executor().clone();
+
+            let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
+                AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
+                stdin,
+                stdout,
+                move |fut| foreground_executor.spawn(fut).detach(),
+            );
+
+            let io_task = cx.background_spawn(async move {
+                io_fut.await.log_err();
+            });
+
+            let child_status = cx.background_spawn(async move {
+                match child.status().await {
+                    Err(e) => Err(anyhow!(e)),
+                    Ok(result) if result.success() => Ok(()),
+                    Ok(result) => {
+                        if let Some(version) = server.version(&command).await.log_err()
+                            && !version.supported
+                        {
+                            Err(anyhow!(LoadError::Unsupported {
+                                current_version: version.current_version
+                            }))
+                        } else {
+                            Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
+                        }
+                    }
+                }
+            });
+
+            Self {
+                entries: Default::default(),
+                title: "ACP Thread".into(),
+                project,
+                send_task: None,
+                connection: Arc::new(connection),
+                child_status: Some(child_status),
+                _io_task: io_task,
+            }
+        })
+    }
+
+    #[cfg(test)]
+    pub fn fake(
+        stdin: async_pipe::PipeWriter,
+        stdout: async_pipe::PipeReader,
+        project: Entity<Project>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let foreground_executor = cx.foreground_executor().clone();
+
+        let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
+            AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
+            stdin,
+            stdout,
+            move |fut| {
+                foreground_executor.spawn(fut).detach();
+            },
+        );
+
+        let io_task = cx.background_spawn({
+            async move {
+                io_fut.await.log_err();
+            }
+        });
+
+        Self {
+            entries: Default::default(),
+            title: "ACP Thread".into(),
+            project,
+            send_task: None,
+            connection: Arc::new(connection),
+            child_status: None,
+            _io_task: io_task,
+        }
+    }
+
+    pub fn title(&self) -> SharedString {
+        self.title.clone()
+    }
+
+    pub fn entries(&self) -> &[AgentThreadEntry] {
+        &self.entries
+    }
+
+    pub fn status(&self) -> ThreadStatus {
+        if self.send_task.is_some() {
+            if self.waiting_for_tool_confirmation() {
+                ThreadStatus::WaitingForToolConfirmation
+            } else {
+                ThreadStatus::Generating
+            }
+        } else {
+            ThreadStatus::Idle
+        }
+    }
+
+    pub fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context<Self>) {
+        self.entries.push(entry);
+        cx.emit(AcpThreadEvent::NewEntry);
+    }
+
+    pub fn push_assistant_chunk(
+        &mut self,
+        chunk: acp::AssistantMessageChunk,
+        cx: &mut Context<Self>,
+    ) {
+        let entries_len = self.entries.len();
+        if let Some(last_entry) = self.entries.last_mut()
+            && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
+        {
+            cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
+
+            match (chunks.last_mut(), &chunk) {
+                (
+                    Some(AssistantMessageChunk::Text { chunk: old_chunk }),
+                    acp::AssistantMessageChunk::Text { chunk: new_chunk },
+                )
+                | (
+                    Some(AssistantMessageChunk::Thought { chunk: old_chunk }),
+                    acp::AssistantMessageChunk::Thought { chunk: new_chunk },
+                ) => {
+                    old_chunk.update(cx, |old_chunk, cx| {
+                        old_chunk.append(&new_chunk, cx);
+                    });
+                }
+                _ => {
+                    chunks.push(AssistantMessageChunk::from_acp(
+                        chunk,
+                        self.project.read(cx).languages().clone(),
+                        cx,
+                    ));
+                }
+            }
+        } else {
+            let chunk = AssistantMessageChunk::from_acp(
+                chunk,
+                self.project.read(cx).languages().clone(),
+                cx,
+            );
+
+            self.push_entry(
+                AgentThreadEntry::AssistantMessage(AssistantMessage {
+                    chunks: vec![chunk],
+                }),
+                cx,
+            );
+        }
+    }
+
+    pub fn request_tool_call(
+        &mut self,
+        label: String,
+        icon: acp::Icon,
+        content: Option<acp::ToolCallContent>,
+        confirmation: acp::ToolCallConfirmation,
+        cx: &mut Context<Self>,
+    ) -> ToolCallRequest {
+        let (tx, rx) = oneshot::channel();
+
+        let status = ToolCallStatus::WaitingForConfirmation {
+            confirmation: ToolCallConfirmation::from_acp(
+                confirmation,
+                self.project.read(cx).languages().clone(),
+                cx,
+            ),
+            respond_tx: tx,
+        };
+
+        let id = self.insert_tool_call(label, status, icon, content, cx);
+        ToolCallRequest { id, outcome: rx }
+    }
+
+    pub fn push_tool_call(
+        &mut self,
+        label: String,
+        icon: acp::Icon,
+        content: Option<acp::ToolCallContent>,
+        cx: &mut Context<Self>,
+    ) -> acp::ToolCallId {
+        let status = ToolCallStatus::Allowed {
+            status: acp::ToolCallStatus::Running,
+        };
+
+        self.insert_tool_call(label, status, icon, content, cx)
+    }
+
+    fn insert_tool_call(
+        &mut self,
+        label: String,
+        status: ToolCallStatus,
+        icon: acp::Icon,
+        content: Option<acp::ToolCallContent>,
+        cx: &mut Context<Self>,
+    ) -> acp::ToolCallId {
+        let language_registry = self.project.read(cx).languages().clone();
+        let id = acp::ToolCallId(self.entries.len() as u64);
+
+        self.push_entry(
+            AgentThreadEntry::ToolCall(ToolCall {
+                id,
+                label: cx.new(|cx| {
+                    Markdown::new(label.into(), Some(language_registry.clone()), None, cx)
+                }),
+                icon: acp_icon_to_ui_icon(icon),
+                content: content
+                    .map(|content| ToolCallContent::from_acp(content, language_registry, cx)),
+                status,
+            }),
+            cx,
+        );
+
+        id
+    }
+
+    pub fn authorize_tool_call(
+        &mut self,
+        id: acp::ToolCallId,
+        outcome: acp::ToolCallConfirmationOutcome,
+        cx: &mut Context<Self>,
+    ) {
+        let Some((ix, call)) = self.tool_call_mut(id) else {
+            return;
+        };
+
+        let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject {
+            ToolCallStatus::Rejected
+        } else {
+            ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Running,
+            }
+        };
+
+        let curr_status = mem::replace(&mut call.status, new_status);
+
+        if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status {
+            respond_tx.send(outcome).log_err();
+        } else if cfg!(debug_assertions) {
+            panic!("tried to authorize an already authorized tool call");
+        }
+
+        cx.emit(AcpThreadEvent::EntryUpdated(ix));
+    }
+
+    pub fn update_tool_call(
+        &mut self,
+        id: acp::ToolCallId,
+        new_status: acp::ToolCallStatus,
+        new_content: Option<acp::ToolCallContent>,
+        cx: &mut Context<Self>,
+    ) -> Result<()> {
+        let language_registry = self.project.read(cx).languages().clone();
+        let (ix, call) = self.tool_call_mut(id).context("Entry not found")?;
+
+        call.content = new_content
+            .map(|new_content| ToolCallContent::from_acp(new_content, language_registry, cx));
+
+        match &mut call.status {
+            ToolCallStatus::Allowed { status } => {
+                *status = new_status;
+            }
+            ToolCallStatus::WaitingForConfirmation { .. } => {
+                anyhow::bail!("Tool call hasn't been authorized yet")
+            }
+            ToolCallStatus::Rejected => {
+                anyhow::bail!("Tool call was rejected and therefore can't be updated")
+            }
+            ToolCallStatus::Canceled => {
+                call.status = ToolCallStatus::Allowed { status: new_status };
+            }
+        }
+
+        cx.emit(AcpThreadEvent::EntryUpdated(ix));
+        Ok(())
+    }
+
+    fn tool_call_mut(&mut self, id: acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
+        let entry = self.entries.get_mut(id.0 as usize);
+        debug_assert!(
+            entry.is_some(),
+            "We shouldn't give out ids to entries that don't exist"
+        );
+        match entry {
+            Some(AgentThreadEntry::ToolCall(call)) if call.id == id => Some((id.0 as usize, call)),
+            _ => {
+                if cfg!(debug_assertions) {
+                    panic!("entry is not a tool call");
+                }
+                None
+            }
+        }
+    }
+
+    /// Returns true if the last turn is awaiting tool authorization
+    pub fn waiting_for_tool_confirmation(&self) -> bool {
+        for entry in self.entries.iter().rev() {
+            match &entry {
+                AgentThreadEntry::ToolCall(call) => match call.status {
+                    ToolCallStatus::WaitingForConfirmation { .. } => return true,
+                    ToolCallStatus::Allowed { .. }
+                    | ToolCallStatus::Rejected
+                    | ToolCallStatus::Canceled => continue,
+                },
+                AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => {
+                    // Reached the beginning of the turn
+                    return false;
+                }
+            }
+        }
+        false
+    }
+
+    pub fn initialize(&self) -> impl use<> + Future<Output = Result<acp::InitializeResponse>> {
+        let connection = self.connection.clone();
+        async move { Ok(connection.request(acp::InitializeParams).await?) }
+    }
+
+    pub fn authenticate(&self) -> impl use<> + Future<Output = Result<()>> {
+        let connection = self.connection.clone();
+        async move { Ok(connection.request(acp::AuthenticateParams).await?) }
+    }
+
+    pub fn send(
+        &mut self,
+        message: impl Into<acp::UserMessage>,
+        cx: &mut Context<Self>,
+    ) -> BoxFuture<'static, Result<()>> {
+        let agent = self.connection.clone();
+        let message = message.into();
+        self.push_entry(
+            AgentThreadEntry::UserMessage(UserMessage::from_acp(
+                message.clone(),
+                self.project.read(cx).languages().clone(),
+                cx,
+            )),
+            cx,
+        );
+
+        let (tx, rx) = oneshot::channel();
+        let cancel = self.cancel(cx);
+
+        self.send_task = Some(cx.spawn(async move |this, cx| {
+            cancel.await.log_err();
+
+            let result = agent.request(acp::SendUserMessageParams { message }).await;
+            tx.send(result).log_err();
+            this.update(cx, |this, _cx| this.send_task.take()).log_err();
+        }));
+
+        async move {
+            match rx.await {
+                Ok(Err(e)) => Err(e)?,
+                _ => Ok(()),
+            }
+        }
+        .boxed()
+    }
+
+    pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let agent = self.connection.clone();
+
+        if self.send_task.take().is_some() {
+            cx.spawn(async move |this, cx| {
+                agent.request(acp::CancelSendMessageParams).await?;
+
+                this.update(cx, |this, _cx| {
+                    for entry in this.entries.iter_mut() {
+                        if let AgentThreadEntry::ToolCall(call) = entry {
+                            let cancel = matches!(
+                                call.status,
+                                ToolCallStatus::WaitingForConfirmation { .. }
+                                    | ToolCallStatus::Allowed {
+                                        status: acp::ToolCallStatus::Running
+                                    }
+                            );
+
+                            if cancel {
+                                let curr_status =
+                                    mem::replace(&mut call.status, ToolCallStatus::Canceled);
+
+                                if let ToolCallStatus::WaitingForConfirmation {
+                                    respond_tx, ..
+                                } = curr_status
+                                {
+                                    respond_tx
+                                        .send(acp::ToolCallConfirmationOutcome::Cancel)
+                                        .ok();
+                                }
+                            }
+                        }
+                    }
+                })
+            })
+        } else {
+            Task::ready(Ok(()))
+        }
+    }
+
+    pub fn child_status(&mut self) -> Option<Task<Result<()>>> {
+        self.child_status.take()
+    }
+
+    pub fn to_markdown(&self, cx: &App) -> String {
+        self.entries.iter().map(|e| e.to_markdown(cx)).collect()
+    }
+}
+
+struct AcpClientDelegate {
+    thread: WeakEntity<AcpThread>,
+    cx: AsyncApp,
+    // sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
+}
+
+impl AcpClientDelegate {
+    fn new(thread: WeakEntity<AcpThread>, cx: AsyncApp) -> Self {
+        Self { thread, cx }
+    }
+}
+
+impl acp::Client for AcpClientDelegate {
+    async fn stream_assistant_message_chunk(
+        &self,
+        params: acp::StreamAssistantMessageChunkParams,
+    ) -> Result<()> {
+        let cx = &mut self.cx.clone();
+
+        cx.update(|cx| {
+            self.thread
+                .update(cx, |thread, cx| {
+                    thread.push_assistant_chunk(params.chunk, cx)
+                })
+                .ok();
+        })?;
+
+        Ok(())
+    }
+
+    async fn request_tool_call_confirmation(
+        &self,
+        request: acp::RequestToolCallConfirmationParams,
+    ) -> Result<acp::RequestToolCallConfirmationResponse> {
+        let cx = &mut self.cx.clone();
+        let ToolCallRequest { id, outcome } = cx
+            .update(|cx| {
+                self.thread.update(cx, |thread, cx| {
+                    thread.request_tool_call(
+                        request.label,
+                        request.icon,
+                        request.content,
+                        request.confirmation,
+                        cx,
+                    )
+                })
+            })?
+            .context("Failed to update thread")?;
+
+        Ok(acp::RequestToolCallConfirmationResponse {
+            id,
+            outcome: outcome.await?,
+        })
+    }
+
+    async fn push_tool_call(
+        &self,
+        request: acp::PushToolCallParams,
+    ) -> Result<acp::PushToolCallResponse> {
+        let cx = &mut self.cx.clone();
+        let id = cx
+            .update(|cx| {
+                self.thread.update(cx, |thread, cx| {
+                    thread.push_tool_call(request.label, request.icon, request.content, cx)
+                })
+            })?
+            .context("Failed to update thread")?;
+
+        Ok(acp::PushToolCallResponse { id })
+    }
+
+    async fn update_tool_call(&self, request: acp::UpdateToolCallParams) -> Result<()> {
+        let cx = &mut self.cx.clone();
+
+        cx.update(|cx| {
+            self.thread.update(cx, |thread, cx| {
+                thread.update_tool_call(request.tool_call_id, request.status, request.content, cx)
+            })
+        })?
+        .context("Failed to update thread")??;
+
+        Ok(())
+    }
+}
+
+fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName {
+    match icon {
+        acp::Icon::FileSearch => IconName::ToolSearch,
+        acp::Icon::Folder => IconName::ToolFolder,
+        acp::Icon::Globe => IconName::ToolWeb,
+        acp::Icon::Hammer => IconName::ToolHammer,
+        acp::Icon::LightBulb => IconName::ToolBulb,
+        acp::Icon::Pencil => IconName::ToolPencil,
+        acp::Icon::Regex => IconName::ToolRegex,
+        acp::Icon::Terminal => IconName::ToolTerminal,
+    }
+}
+
+pub struct ToolCallRequest {
+    pub id: acp::ToolCallId,
+    pub outcome: oneshot::Receiver<acp::ToolCallConfirmationOutcome>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use agent_servers::{AgentServerCommand, AgentServerVersion};
+    use async_pipe::{PipeReader, PipeWriter};
+    use futures::{channel::mpsc, future::LocalBoxFuture, select};
+    use gpui::{AsyncApp, TestAppContext};
+    use indoc::indoc;
+    use project::FakeFs;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use smol::{future::BoxedLocal, stream::StreamExt as _};
+    use std::{cell::RefCell, env, path::Path, rc::Rc, time::Duration};
+    use util::path;
+
+    fn init_test(cx: &mut TestAppContext) {
+        env_logger::try_init().ok();
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            Project::init_settings(cx);
+            language::init(cx);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_thinking_concatenation(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.executor().allow_parking();
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (thread, fake_server) = fake_acp_thread(project, cx);
+
+        fake_server.update(cx, |fake_server, _| {
+            fake_server.on_user_message(move |_, server, mut cx| async move {
+                server
+                    .update(&mut cx, |server, _| {
+                        server.send_to_zed(acp::StreamAssistantMessageChunkParams {
+                            chunk: acp::AssistantMessageChunk::Thought {
+                                chunk: "Thinking ".into(),
+                            },
+                        })
+                    })?
+                    .await
+                    .unwrap();
+                server
+                    .update(&mut cx, |server, _| {
+                        server.send_to_zed(acp::StreamAssistantMessageChunkParams {
+                            chunk: acp::AssistantMessageChunk::Thought {
+                                chunk: "hard!".into(),
+                            },
+                        })
+                    })?
+                    .await
+                    .unwrap();
+
+                Ok(())
+            })
+        });
+
+        thread
+            .update(cx, |thread, cx| thread.send("Hello from Zed!", cx))
+            .await
+            .unwrap();
+
+        let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
+        assert_eq!(
+            output,
+            indoc! {r#"
+            ## User
+
+            Hello from Zed!
+
+            ## Assistant
+
+            <thinking>
+            Thinking hard!
+            </thinking>
+
+            "#}
+        );
+    }
+
+    #[gpui::test]
+    async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (thread, fake_server) = fake_acp_thread(project, cx);
+
+        let (end_turn_tx, end_turn_rx) = oneshot::channel::<()>();
+
+        let tool_call_id = Rc::new(RefCell::new(None));
+        let end_turn_rx = Rc::new(RefCell::new(Some(end_turn_rx)));
+        fake_server.update(cx, |fake_server, _| {
+            let tool_call_id = tool_call_id.clone();
+            fake_server.on_user_message(move |_, server, mut cx| {
+                let end_turn_rx = end_turn_rx.clone();
+                let tool_call_id = tool_call_id.clone();
+                async move {
+                    let tool_call_result = server
+                        .update(&mut cx, |server, _| {
+                            server.send_to_zed(acp::PushToolCallParams {
+                                label: "Fetch".to_string(),
+                                icon: acp::Icon::Globe,
+                                content: None,
+                            })
+                        })?
+                        .await
+                        .unwrap();
+                    *tool_call_id.clone().borrow_mut() = Some(tool_call_result.id);
+                    end_turn_rx.take().unwrap().await.ok();
+
+                    Ok(())
+                }
+            })
+        });
+
+        let request = thread.update(cx, |thread, cx| {
+            thread.send("Fetch https://example.com", cx)
+        });
+
+        run_until_first_tool_call(&thread, cx).await;
+
+        thread.read_with(cx, |thread, _| {
+            assert!(matches!(
+                thread.entries[1],
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::Allowed {
+                        status: acp::ToolCallStatus::Running,
+                        ..
+                    },
+                    ..
+                })
+            ));
+        });
+
+        cx.run_until_parked();
+
+        thread
+            .update(cx, |thread, cx| thread.cancel(cx))
+            .await
+            .unwrap();
+
+        thread.read_with(cx, |thread, _| {
+            assert!(matches!(
+                &thread.entries[1],
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::Canceled,
+                    ..
+                })
+            ));
+        });
+
+        fake_server
+            .update(cx, |fake_server, _| {
+                fake_server.send_to_zed(acp::UpdateToolCallParams {
+                    tool_call_id: tool_call_id.borrow().unwrap(),
+                    status: acp::ToolCallStatus::Finished,
+                    content: None,
+                })
+            })
+            .await
+            .unwrap();
+
+        drop(end_turn_tx);
+        request.await.unwrap();
+
+        thread.read_with(cx, |thread, _| {
+            assert!(matches!(
+                thread.entries[1],
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::Allowed {
+                        status: acp::ToolCallStatus::Finished,
+                        ..
+                    },
+                    ..
+                })
+            ));
+        });
+    }
+
+    #[gpui::test]
+    #[cfg_attr(not(feature = "gemini"), ignore)]
+    async fn test_gemini_basic(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.executor().allow_parking();
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
+        thread
+            .update(cx, |thread, cx| thread.send("Hello from Zed!", cx))
+            .await
+            .unwrap();
+
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.entries.len(), 2);
+            assert!(matches!(
+                thread.entries[0],
+                AgentThreadEntry::UserMessage(_)
+            ));
+            assert!(matches!(
+                thread.entries[1],
+                AgentThreadEntry::AssistantMessage(_)
+            ));
+        });
+    }
+
+    #[gpui::test]
+    #[cfg_attr(not(feature = "gemini"), ignore)]
+    async fn test_gemini_path_mentions(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.executor().allow_parking();
+        let tempdir = tempfile::tempdir().unwrap();
+        std::fs::write(
+            tempdir.path().join("foo.rs"),
+            indoc! {"
+                fn main() {
+                    println!(\"Hello, world!\");
+                }
+            "},
+        )
+        .expect("failed to write file");
+        let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
+        let thread = gemini_acp_thread(project.clone(), tempdir.path(), cx).await;
+        thread
+            .update(cx, |thread, cx| {
+                thread.send(
+                    acp::UserMessage {
+                        chunks: vec![
+                            "Read the file ".into(),
+                            Path::new("foo.rs").into(),
+                            " and tell me what the content of the println! is".into(),
+                        ],
+                    },
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        thread.read_with(cx, |thread, cx| {
+            assert_eq!(thread.entries.len(), 3);
+            assert!(matches!(
+                thread.entries[0],
+                AgentThreadEntry::UserMessage(_)
+            ));
+            assert!(matches!(thread.entries[1], AgentThreadEntry::ToolCall(_)));
+            let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries[2] else {
+                panic!("Expected AssistantMessage")
+            };
+            assert!(
+                assistant_message.to_markdown(cx).contains("Hello, world!"),
+                "unexpected assistant message: {:?}",
+                assistant_message.to_markdown(cx)
+            );
+        });
+    }
+
+    #[gpui::test]
+    #[cfg_attr(not(feature = "gemini"), ignore)]
+    async fn test_gemini_tool_call(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.executor().allow_parking();
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/private/tmp"),
+            json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
+        )
+        .await;
+        let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
+        let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
+        thread
+            .update(cx, |thread, cx| {
+                thread.send(
+                    "Read the '/private/tmp/foo' file and tell me what you see.",
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        thread.read_with(cx, |thread, _cx| {
+            assert!(matches!(
+                &thread.entries()[2],
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::Allowed { .. },
+                    ..
+                })
+            ));
+
+            assert!(matches!(
+                thread.entries[3],
+                AgentThreadEntry::AssistantMessage(_)
+            ));
+        });
+    }
+
+    #[gpui::test]
+    #[cfg_attr(not(feature = "gemini"), ignore)]
+    async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.executor().allow_parking();
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
+        let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
+        let full_turn = thread.update(cx, |thread, cx| {
+            thread.send(r#"Run `echo "Hello, world!"`"#, cx)
+        });
+
+        run_until_first_tool_call(&thread, cx).await;
+
+        let tool_call_id = thread.read_with(cx, |thread, _cx| {
+            let AgentThreadEntry::ToolCall(ToolCall {
+                id,
+                status:
+                    ToolCallStatus::WaitingForConfirmation {
+                        confirmation: ToolCallConfirmation::Execute { root_command, .. },
+                        ..
+                    },
+                ..
+            }) = &thread.entries()[2]
+            else {
+                panic!();
+            };
+
+            assert_eq!(root_command, "echo");
+
+            *id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
+
+            assert!(matches!(
+                &thread.entries()[2],
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::Allowed { .. },
+                    ..
+                })
+            ));
+        });
+
+        full_turn.await.unwrap();
+
+        thread.read_with(cx, |thread, cx| {
+            let AgentThreadEntry::ToolCall(ToolCall {
+                content: Some(ToolCallContent::Markdown { markdown }),
+                status: ToolCallStatus::Allowed { .. },
+                ..
+            }) = &thread.entries()[2]
+            else {
+                panic!();
+            };
+
+            markdown.read_with(cx, |md, _cx| {
+                assert!(
+                    md.source().contains("Hello, world!"),
+                    r#"Expected '{}' to contain "Hello, world!""#,
+                    md.source()
+                );
+            });
+        });
+    }
+
+    #[gpui::test]
+    #[cfg_attr(not(feature = "gemini"), ignore)]
+    async fn test_gemini_cancel(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.executor().allow_parking();
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
+        let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
+        let full_turn = thread.update(cx, |thread, cx| {
+            thread.send(r#"Run `echo "Hello, world!"`"#, cx)
+        });
+
+        let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await;
+
+        thread.read_with(cx, |thread, _cx| {
+            let AgentThreadEntry::ToolCall(ToolCall {
+                id,
+                status:
+                    ToolCallStatus::WaitingForConfirmation {
+                        confirmation: ToolCallConfirmation::Execute { root_command, .. },
+                        ..
+                    },
+                ..
+            }) = &thread.entries()[first_tool_call_ix]
+            else {
+                panic!("{:?}", thread.entries()[1]);
+            };
+
+            assert_eq!(root_command, "echo");
+
+            *id
+        });
+
+        thread
+            .update(cx, |thread, cx| thread.cancel(cx))
+            .await
+            .unwrap();
+        full_turn.await.unwrap();
+        thread.read_with(cx, |thread, _| {
+            let AgentThreadEntry::ToolCall(ToolCall {
+                status: ToolCallStatus::Canceled,
+                ..
+            }) = &thread.entries()[first_tool_call_ix]
+            else {
+                panic!();
+            };
+        });
+
+        thread
+            .update(cx, |thread, cx| {
+                thread.send(r#"Stop running and say goodbye to me."#, cx)
+            })
+            .await
+            .unwrap();
+        thread.read_with(cx, |thread, _| {
+            assert!(matches!(
+                &thread.entries().last().unwrap(),
+                AgentThreadEntry::AssistantMessage(..),
+            ))
+        });
+    }
+
+    async fn run_until_first_tool_call(
+        thread: &Entity<AcpThread>,
+        cx: &mut TestAppContext,
+    ) -> usize {
+        let (mut tx, mut rx) = mpsc::channel::<usize>(1);
+
+        let subscription = cx.update(|cx| {
+            cx.subscribe(thread, move |thread, _, cx| {
+                for (ix, entry) in thread.read(cx).entries.iter().enumerate() {
+                    if matches!(entry, AgentThreadEntry::ToolCall(_)) {
+                        return tx.try_send(ix).unwrap();
+                    }
+                }
+            })
+        });
+
+        select! {
+            _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => {
+                panic!("Timeout waiting for tool call")
+            }
+            ix = rx.next().fuse() => {
+                drop(subscription);
+                ix.unwrap()
+            }
+        }
+    }
+
+    pub async fn gemini_acp_thread(
+        project: Entity<Project>,
+        current_dir: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) -> Entity<AcpThread> {
+        struct DevGemini;
+
+        impl agent_servers::AgentServer for DevGemini {
+            async fn command(
+                &self,
+                _project: &Entity<Project>,
+                _cx: &mut AsyncApp,
+            ) -> Result<agent_servers::AgentServerCommand> {
+                let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
+                    .join("../../../gemini-cli/packages/cli")
+                    .to_string_lossy()
+                    .to_string();
+
+                Ok(AgentServerCommand {
+                    path: "node".into(),
+                    args: vec![cli_path, "--acp".into()],
+                    env: None,
+                })
+            }
+
+            async fn version(
+                &self,
+                _command: &agent_servers::AgentServerCommand,
+            ) -> Result<AgentServerVersion> {
+                Ok(AgentServerVersion {
+                    current_version: "0.1.0".into(),
+                    supported: true,
+                })
+            }
+        }
+
+        let thread = AcpThread::spawn(DevGemini, current_dir.as_ref(), project, &mut cx.to_async())
+            .await
+            .unwrap();
+
+        thread
+            .update(cx, |thread, _| thread.initialize())
+            .await
+            .unwrap();
+        thread
+    }
+
+    pub fn fake_acp_thread(
+        project: Entity<Project>,
+        cx: &mut TestAppContext,
+    ) -> (Entity<AcpThread>, Entity<FakeAcpServer>) {
+        let (stdin_tx, stdin_rx) = async_pipe::pipe();
+        let (stdout_tx, stdout_rx) = async_pipe::pipe();
+        let thread = cx.update(|cx| cx.new(|cx| AcpThread::fake(stdin_tx, stdout_rx, project, cx)));
+        let agent = cx.update(|cx| cx.new(|cx| FakeAcpServer::new(stdin_rx, stdout_tx, cx)));
+        (thread, agent)
+    }
+
+    pub struct FakeAcpServer {
+        connection: acp::ClientConnection,
+        _io_task: Task<()>,
+        on_user_message: Option<
+            Rc<
+                dyn Fn(
+                    acp::SendUserMessageParams,
+                    Entity<FakeAcpServer>,
+                    AsyncApp,
+                ) -> LocalBoxFuture<'static, Result<()>>,
+            >,
+        >,
+    }
+
+    #[derive(Clone)]
+    struct FakeAgent {
+        server: Entity<FakeAcpServer>,
+        cx: AsyncApp,
+    }
+
+    impl acp::Agent for FakeAgent {
+        async fn initialize(&self) -> Result<acp::InitializeResponse> {
+            Ok(acp::InitializeResponse {
+                is_authenticated: true,
+            })
+        }
+
+        async fn authenticate(&self) -> Result<()> {
+            Ok(())
+        }
+
+        async fn cancel_send_message(&self) -> Result<()> {
+            Ok(())
+        }
+
+        async fn send_user_message(&self, request: acp::SendUserMessageParams) -> Result<()> {
+            let mut cx = self.cx.clone();
+            let handler = self
+                .server
+                .update(&mut cx, |server, _| server.on_user_message.clone())
+                .ok()
+                .flatten();
+            if let Some(handler) = handler {
+                handler(request, self.server.clone(), self.cx.clone()).await
+            } else {
+                anyhow::bail!("No handler for on_user_message")
+            }
+        }
+    }
+
+    impl FakeAcpServer {
+        fn new(stdin: PipeReader, stdout: PipeWriter, cx: &Context<Self>) -> Self {
+            let agent = FakeAgent {
+                server: cx.entity(),
+                cx: cx.to_async(),
+            };
+            let foreground_executor = cx.foreground_executor().clone();
+
+            let (connection, io_fut) = acp::ClientConnection::connect_to_client(
+                agent.clone(),
+                stdout,
+                stdin,
+                move |fut| {
+                    foreground_executor.spawn(fut).detach();
+                },
+            );
+            FakeAcpServer {
+                connection: connection,
+                on_user_message: None,
+                _io_task: cx.background_spawn(async move {
+                    io_fut.await.log_err();
+                }),
+            }
+        }
+
+        fn on_user_message<F>(
+            &mut self,
+            handler: impl for<'a> Fn(acp::SendUserMessageParams, Entity<FakeAcpServer>, AsyncApp) -> F
+            + 'static,
+        ) where
+            F: Future<Output = Result<()>> + 'static,
+        {
+            self.on_user_message
+                .replace(Rc::new(move |request, server, cx| {
+                    handler(request, server, cx).boxed_local()
+                }));
+        }
+
+        fn send_to_zed<T: acp::ClientRequest + 'static>(
+            &self,
+            message: T,
+        ) -> BoxedLocal<Result<T::Response>> {
+            self.connection
+                .request(message)
+                .map(|f| f.map_err(|err| anyhow!(err)))
+                .boxed_local()
+        }
+    }
+}

crates/agent_servers/Cargo.toml πŸ”—

@@ -0,0 +1,27 @@
+[package]
+name = "agent_servers"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/agent_servers.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+futures.workspace = true
+gpui.workspace = true
+paths.workspace = true
+project.workspace = true
+schemars.workspace = true
+serde.workspace = true
+settings.workspace = true
+util.workspace = true
+which.workspace = true
+workspace-hack.workspace = true

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

@@ -0,0 +1,231 @@
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use gpui::{App, AsyncApp, Entity, SharedString};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources, SettingsStore};
+use util::{ResultExt, paths};
+
+pub fn init(cx: &mut App) {
+    AllAgentServersSettings::register(cx);
+}
+
+#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
+pub struct AllAgentServersSettings {
+    gemini: Option<AgentServerSettings>,
+}
+
+#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
+pub struct AgentServerSettings {
+    #[serde(flatten)]
+    command: AgentServerCommand,
+}
+
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
+pub struct AgentServerCommand {
+    #[serde(rename = "command")]
+    pub path: PathBuf,
+    #[serde(default)]
+    pub args: Vec<String>,
+    pub env: Option<HashMap<String, String>>,
+}
+
+pub struct Gemini;
+
+pub struct AgentServerVersion {
+    pub current_version: SharedString,
+    pub supported: bool,
+}
+
+pub trait AgentServer: Send {
+    fn command(
+        &self,
+        project: &Entity<Project>,
+        cx: &mut AsyncApp,
+    ) -> impl Future<Output = Result<AgentServerCommand>>;
+
+    fn version(
+        &self,
+        command: &AgentServerCommand,
+    ) -> impl Future<Output = Result<AgentServerVersion>> + Send;
+}
+
+const GEMINI_ACP_ARG: &str = "--acp";
+
+impl AgentServer for Gemini {
+    async fn command(
+        &self,
+        project: &Entity<Project>,
+        cx: &mut AsyncApp,
+    ) -> Result<AgentServerCommand> {
+        let custom_command = cx.read_global(|settings: &SettingsStore, _| {
+            let settings = settings.get::<AllAgentServersSettings>(None);
+            settings
+                .gemini
+                .as_ref()
+                .map(|gemini_settings| AgentServerCommand {
+                    path: gemini_settings.command.path.clone(),
+                    args: gemini_settings
+                        .command
+                        .args
+                        .iter()
+                        .cloned()
+                        .chain(std::iter::once(GEMINI_ACP_ARG.into()))
+                        .collect(),
+                    env: gemini_settings.command.env.clone(),
+                })
+        })?;
+
+        if let Some(custom_command) = custom_command {
+            return Ok(custom_command);
+        }
+
+        if let Some(path) = find_bin_in_path("gemini", project, cx).await {
+            return Ok(AgentServerCommand {
+                path,
+                args: vec![GEMINI_ACP_ARG.into()],
+                env: None,
+            });
+        }
+
+        let (fs, node_runtime) = project.update(cx, |project, _| {
+            (project.fs().clone(), project.node_runtime().cloned())
+        })?;
+        let node_runtime = node_runtime.context("gemini not found on path")?;
+
+        let directory = ::paths::agent_servers_dir().join("gemini");
+        fs.create_dir(&directory).await?;
+        node_runtime
+            .npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
+            .await?;
+        let path = directory.join("node_modules/.bin/gemini");
+
+        Ok(AgentServerCommand {
+            path,
+            args: vec![GEMINI_ACP_ARG.into()],
+            env: None,
+        })
+    }
+
+    async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
+        let version_fut = util::command::new_smol_command(&command.path)
+            .args(command.args.iter())
+            .arg("--version")
+            .kill_on_drop(true)
+            .output();
+
+        let help_fut = util::command::new_smol_command(&command.path)
+            .args(command.args.iter())
+            .arg("--help")
+            .kill_on_drop(true)
+            .output();
+
+        let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
+
+        let current_version = String::from_utf8(version_output?.stdout)?.into();
+        let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG);
+
+        Ok(AgentServerVersion {
+            current_version,
+            supported,
+        })
+    }
+}
+
+async fn find_bin_in_path(
+    bin_name: &'static str,
+    project: &Entity<Project>,
+    cx: &mut AsyncApp,
+) -> Option<PathBuf> {
+    let (env_task, root_dir) = project
+        .update(cx, |project, cx| {
+            let worktree = project.visible_worktrees(cx).next();
+            match worktree {
+                Some(worktree) => {
+                    let env_task = project.environment().update(cx, |env, cx| {
+                        env.get_worktree_environment(worktree.clone(), cx)
+                    });
+
+                    let path = worktree.read(cx).abs_path();
+                    (env_task, path)
+                }
+                None => {
+                    let path: Arc<Path> = paths::home_dir().as_path().into();
+                    let env_task = project.environment().update(cx, |env, cx| {
+                        env.get_directory_environment(path.clone(), cx)
+                    });
+                    (env_task, path)
+                }
+            }
+        })
+        .log_err()?;
+
+    cx.background_executor()
+        .spawn(async move {
+            let which_result = if cfg!(windows) {
+                which::which(bin_name)
+            } else {
+                let env = env_task.await.unwrap_or_default();
+                let shell_path = env.get("PATH").cloned();
+                which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref())
+            };
+
+            if let Err(which::Error::CannotFindBinaryPath) = which_result {
+                return None;
+            }
+
+            which_result.log_err()
+        })
+        .await
+}
+
+impl std::fmt::Debug for AgentServerCommand {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let filtered_env = self.env.as_ref().map(|env| {
+            env.iter()
+                .map(|(k, v)| {
+                    (
+                        k,
+                        if util::redact::should_redact(k) {
+                            "[REDACTED]"
+                        } else {
+                            v
+                        },
+                    )
+                })
+                .collect::<Vec<_>>()
+        });
+
+        f.debug_struct("AgentServerCommand")
+            .field("path", &self.path)
+            .field("args", &self.args)
+            .field("env", &filtered_env)
+            .finish()
+    }
+}
+
+impl settings::Settings for AllAgentServersSettings {
+    const KEY: Option<&'static str> = Some("agent_servers");
+
+    type FileContent = Self;
+
+    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
+        let mut settings = AllAgentServersSettings::default();
+
+        for value in sources.defaults_and_customizations() {
+            if value.gemini.is_some() {
+                settings.gemini = value.gemini.clone();
+            }
+        }
+
+        Ok(settings)
+    }
+
+    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
+}

crates/agent_ui/Cargo.toml πŸ”—

@@ -13,14 +13,14 @@ path = "src/agent_ui.rs"
 doctest = false
 
 [features]
-test-support = [
-    "gpui/test-support",
-    "language/test-support",
-]
+test-support = ["gpui/test-support", "language/test-support"]
 
 [dependencies]
+acp.workspace = true
 agent.workspace = true
+agentic-coding-protocol.workspace = true
 agent_settings.workspace = true
+agent_servers.workspace = true
 anyhow.workspace = true
 assistant_context.workspace = true
 assistant_slash_command.workspace = true
@@ -76,6 +76,7 @@ serde_json_lenient.workspace = true
 settings.workspace = true
 smol.workspace = true
 streaming_diff.workspace = true
+task.workspace = true
 telemetry.workspace = true
 telemetry_events.workspace = true
 terminal.workspace = true

crates/agent_ui/src/acp/completion_provider.rs πŸ”—

@@ -0,0 +1,574 @@
+use std::ops::Range;
+use std::path::Path;
+use std::sync::Arc;
+use std::sync::atomic::AtomicBool;
+
+use anyhow::Result;
+use collections::HashMap;
+use editor::display_map::CreaseId;
+use editor::{CompletionProvider, Editor, ExcerptId};
+use file_icons::FileIcons;
+use gpui::{App, Entity, Task, WeakEntity};
+use language::{Buffer, CodeLabel, HighlightId};
+use lsp::CompletionContext;
+use parking_lot::Mutex;
+use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId};
+use rope::Point;
+use text::{Anchor, ToPoint};
+use ui::prelude::*;
+use workspace::Workspace;
+
+use crate::context_picker::MentionLink;
+use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files};
+
+#[derive(Default)]
+pub struct MentionSet {
+    paths_by_crease_id: HashMap<CreaseId, ProjectPath>,
+}
+
+impl MentionSet {
+    pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) {
+        self.paths_by_crease_id.insert(crease_id, path);
+    }
+
+    pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option<ProjectPath> {
+        self.paths_by_crease_id.get(&crease_id).cloned()
+    }
+
+    pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
+        self.paths_by_crease_id.drain().map(|(id, _)| id)
+    }
+}
+
+pub struct ContextPickerCompletionProvider {
+    workspace: WeakEntity<Workspace>,
+    editor: WeakEntity<Editor>,
+    mention_set: Arc<Mutex<MentionSet>>,
+}
+
+impl ContextPickerCompletionProvider {
+    pub fn new(
+        mention_set: Arc<Mutex<MentionSet>>,
+        workspace: WeakEntity<Workspace>,
+        editor: WeakEntity<Editor>,
+    ) -> Self {
+        Self {
+            mention_set,
+            workspace,
+            editor,
+        }
+    }
+
+    fn completion_for_path(
+        project_path: ProjectPath,
+        path_prefix: &str,
+        is_recent: bool,
+        is_directory: bool,
+        excerpt_id: ExcerptId,
+        source_range: Range<Anchor>,
+        editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+        cx: &App,
+    ) -> Completion {
+        let (file_name, directory) =
+            extract_file_name_and_directory(&project_path.path, path_prefix);
+
+        let label =
+            build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
+        let full_path = if let Some(directory) = directory {
+            format!("{}{}", directory, file_name)
+        } else {
+            file_name.to_string()
+        };
+
+        let crease_icon_path = if is_directory {
+            FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
+        } else {
+            FileIcons::get_icon(Path::new(&full_path), cx)
+                .unwrap_or_else(|| IconName::File.path().into())
+        };
+        let completion_icon_path = if is_recent {
+            IconName::HistoryRerun.path().into()
+        } else {
+            crease_icon_path.clone()
+        };
+
+        let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
+        let new_text_len = new_text.len();
+        Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label,
+            documentation: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(completion_icon_path),
+            insert_text_mode: None,
+            confirm: Some(confirm_completion_callback(
+                crease_icon_path,
+                file_name,
+                project_path,
+                excerpt_id,
+                source_range.start,
+                new_text_len - 1,
+                editor,
+                mention_set,
+            )),
+        }
+    }
+}
+
+fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
+    let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
+    let mut label = CodeLabel::default();
+
+    label.push_str(&file_name, None);
+    label.push_str(" ", None);
+
+    if let Some(directory) = directory {
+        label.push_str(&directory, comment_id);
+    }
+
+    label.filter_range = 0..label.text().len();
+
+    label
+}
+
+impl CompletionProvider for ContextPickerCompletionProvider {
+    fn completions(
+        &self,
+        excerpt_id: ExcerptId,
+        buffer: &Entity<Buffer>,
+        buffer_position: Anchor,
+        _trigger: CompletionContext,
+        _window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) -> Task<Result<Vec<CompletionResponse>>> {
+        let state = buffer.update(cx, |buffer, _cx| {
+            let position = buffer_position.to_point(buffer);
+            let line_start = Point::new(position.row, 0);
+            let offset_to_line = buffer.point_to_offset(line_start);
+            let mut lines = buffer.text_for_range(line_start..position).lines();
+            let line = lines.next()?;
+            MentionCompletion::try_parse(line, offset_to_line)
+        });
+        let Some(state) = state else {
+            return Task::ready(Ok(Vec::new()));
+        };
+
+        let Some(workspace) = self.workspace.upgrade() else {
+            return Task::ready(Ok(Vec::new()));
+        };
+
+        let snapshot = buffer.read(cx).snapshot();
+        let source_range = snapshot.anchor_before(state.source_range.start)
+            ..snapshot.anchor_after(state.source_range.end);
+
+        let editor = self.editor.clone();
+        let mention_set = self.mention_set.clone();
+        let MentionCompletion { argument, .. } = state;
+        let query = argument.unwrap_or_else(|| "".to_string());
+
+        let search_task = search_files(query.clone(), Arc::<AtomicBool>::default(), &workspace, cx);
+
+        cx.spawn(async move |_, cx| {
+            let matches = search_task.await;
+            let Some(editor) = editor.upgrade() else {
+                return Ok(Vec::new());
+            };
+
+            let completions = cx.update(|cx| {
+                matches
+                    .into_iter()
+                    .map(|mat| {
+                        let path_match = &mat.mat;
+                        let project_path = ProjectPath {
+                            worktree_id: WorktreeId::from_usize(path_match.worktree_id),
+                            path: path_match.path.clone(),
+                        };
+
+                        Self::completion_for_path(
+                            project_path,
+                            &path_match.path_prefix,
+                            mat.is_recent,
+                            path_match.is_dir,
+                            excerpt_id,
+                            source_range.clone(),
+                            editor.clone(),
+                            mention_set.clone(),
+                            cx,
+                        )
+                    })
+                    .collect()
+            })?;
+
+            Ok(vec![CompletionResponse {
+                completions,
+                // Since this does its own filtering (see `filter_completions()` returns false),
+                // there is no benefit to computing whether this set of completions is incomplete.
+                is_incomplete: true,
+            }])
+        })
+    }
+
+    fn is_completion_trigger(
+        &self,
+        buffer: &Entity<language::Buffer>,
+        position: language::Anchor,
+        _text: &str,
+        _trigger_in_words: bool,
+        _menu_is_open: bool,
+        cx: &mut Context<Editor>,
+    ) -> bool {
+        let buffer = buffer.read(cx);
+        let position = position.to_point(buffer);
+        let line_start = Point::new(position.row, 0);
+        let offset_to_line = buffer.point_to_offset(line_start);
+        let mut lines = buffer.text_for_range(line_start..position).lines();
+        if let Some(line) = lines.next() {
+            MentionCompletion::try_parse(line, offset_to_line)
+                .map(|completion| {
+                    completion.source_range.start <= offset_to_line + position.column as usize
+                        && completion.source_range.end >= offset_to_line + position.column as usize
+                })
+                .unwrap_or(false)
+        } else {
+            false
+        }
+    }
+
+    fn sort_completions(&self) -> bool {
+        false
+    }
+
+    fn filter_completions(&self) -> bool {
+        false
+    }
+}
+
+fn confirm_completion_callback(
+    crease_icon_path: SharedString,
+    crease_text: SharedString,
+    project_path: ProjectPath,
+    excerpt_id: ExcerptId,
+    start: Anchor,
+    content_len: usize,
+    editor: Entity<Editor>,
+    mention_set: Arc<Mutex<MentionSet>>,
+) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
+    Arc::new(move |_, window, cx| {
+        let crease_text = crease_text.clone();
+        let crease_icon_path = crease_icon_path.clone();
+        let editor = editor.clone();
+        let project_path = project_path.clone();
+        let mention_set = mention_set.clone();
+        window.defer(cx, move |window, cx| {
+            let crease_id = crate::context_picker::insert_crease_for_mention(
+                excerpt_id,
+                start,
+                content_len,
+                crease_text.clone(),
+                crease_icon_path,
+                editor.clone(),
+                window,
+                cx,
+            );
+            if let Some(crease_id) = crease_id {
+                mention_set.lock().insert(crease_id, project_path);
+            }
+        });
+        false
+    })
+}
+
+#[derive(Debug, Default, PartialEq)]
+struct MentionCompletion {
+    source_range: Range<usize>,
+    argument: Option<String>,
+}
+
+impl MentionCompletion {
+    fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
+        let last_mention_start = line.rfind('@')?;
+        if last_mention_start >= line.len() {
+            return Some(Self::default());
+        }
+        if last_mention_start > 0
+            && line
+                .chars()
+                .nth(last_mention_start - 1)
+                .map_or(false, |c| !c.is_whitespace())
+        {
+            return None;
+        }
+
+        let rest_of_line = &line[last_mention_start + 1..];
+        let mut argument = None;
+
+        let mut parts = rest_of_line.split_whitespace();
+        let mut end = last_mention_start + 1;
+        if let Some(argument_text) = parts.next() {
+            end += argument_text.len();
+            argument = Some(argument_text.to_string());
+        }
+
+        Some(Self {
+            source_range: last_mention_start + offset_to_line..end + offset_to_line,
+            argument,
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
+    use project::{Project, ProjectPath};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use std::{ops::Deref, rc::Rc};
+    use util::path;
+    use workspace::{AppState, Item};
+
+    #[test]
+    fn test_mention_completion_parse() {
+        assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
+
+        assert_eq!(
+            MentionCompletion::try_parse("Lorem @", 0),
+            Some(MentionCompletion {
+                source_range: 6..7,
+                argument: None,
+            })
+        );
+
+        assert_eq!(
+            MentionCompletion::try_parse("Lorem @main", 0),
+            Some(MentionCompletion {
+                source_range: 6..11,
+                argument: Some("main".to_string()),
+            })
+        );
+
+        assert_eq!(MentionCompletion::try_parse("test@", 0), None);
+    }
+
+    struct AtMentionEditor(Entity<Editor>);
+
+    impl Item for AtMentionEditor {
+        type Event = ();
+
+        fn include_in_nav_history() -> bool {
+            false
+        }
+
+        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+            "Test".into()
+        }
+    }
+
+    impl EventEmitter<()> for AtMentionEditor {}
+
+    impl Focusable for AtMentionEditor {
+        fn focus_handle(&self, cx: &App) -> FocusHandle {
+            self.0.read(cx).focus_handle(cx).clone()
+        }
+    }
+
+    impl Render for AtMentionEditor {
+        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+            self.0.clone().into_any_element()
+        }
+    }
+
+    #[gpui::test]
+    async fn test_context_completion_provider(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let app_state = cx.update(AppState::test);
+
+        cx.update(|cx| {
+            language::init(cx);
+            editor::init(cx);
+            workspace::init(app_state.clone(), cx);
+            Project::init_settings(cx);
+        });
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/dir"),
+                json!({
+                    "editor": "",
+                    "a": {
+                        "one.txt": "",
+                        "two.txt": "",
+                        "three.txt": "",
+                        "four.txt": ""
+                    },
+                    "b": {
+                        "five.txt": "",
+                        "six.txt": "",
+                        "seven.txt": "",
+                        "eight.txt": "",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let workspace = window.root(cx).unwrap();
+
+        let worktree = project.update(cx, |project, cx| {
+            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            worktrees.pop().unwrap()
+        });
+        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
+
+        let mut cx = VisualTestContext::from_window(*window.deref(), cx);
+
+        let paths = vec![
+            path!("a/one.txt"),
+            path!("a/two.txt"),
+            path!("a/three.txt"),
+            path!("a/four.txt"),
+            path!("b/five.txt"),
+            path!("b/six.txt"),
+            path!("b/seven.txt"),
+            path!("b/eight.txt"),
+        ];
+
+        let mut opened_editors = Vec::new();
+        for path in paths {
+            let buffer = workspace
+                .update_in(&mut cx, |workspace, window, cx| {
+                    workspace.open_path(
+                        ProjectPath {
+                            worktree_id,
+                            path: Path::new(path).into(),
+                        },
+                        None,
+                        false,
+                        window,
+                        cx,
+                    )
+                })
+                .await
+                .unwrap();
+            opened_editors.push(buffer);
+        }
+
+        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
+            let editor = cx.new(|cx| {
+                Editor::new(
+                    editor::EditorMode::full(),
+                    multi_buffer::MultiBuffer::build_simple("", cx),
+                    None,
+                    window,
+                    cx,
+                )
+            });
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.add_item(
+                    Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
+                    true,
+                    true,
+                    None,
+                    window,
+                    cx,
+                );
+            });
+            editor
+        });
+
+        let mention_set = Arc::new(Mutex::new(MentionSet::default()));
+
+        let editor_entity = editor.downgrade();
+        editor.update_in(&mut cx, |editor, window, cx| {
+            window.focus(&editor.focus_handle(cx));
+            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
+                mention_set.clone(),
+                workspace.downgrade(),
+                editor_entity,
+            ))));
+        });
+
+        cx.simulate_input("Lorem ");
+
+        editor.update(&mut cx, |editor, cx| {
+            assert_eq!(editor.text(cx), "Lorem ");
+            assert!(!editor.has_visible_completions_menu());
+        });
+
+        cx.simulate_input("@");
+
+        editor.update(&mut cx, |editor, cx| {
+            assert_eq!(editor.text(cx), "Lorem @");
+            assert!(editor.has_visible_completions_menu());
+            assert_eq!(
+                current_completion_labels(editor),
+                &[
+                    "eight.txt dir/b/",
+                    "seven.txt dir/b/",
+                    "six.txt dir/b/",
+                    "five.txt dir/b/",
+                    "four.txt dir/a/",
+                    "three.txt dir/a/",
+                    "two.txt dir/a/",
+                    "one.txt dir/a/",
+                    "dir ",
+                    "a dir/",
+                    "four.txt dir/a/",
+                    "one.txt dir/a/",
+                    "three.txt dir/a/",
+                    "two.txt dir/a/",
+                    "b dir/",
+                    "eight.txt dir/b/",
+                    "five.txt dir/b/",
+                    "seven.txt dir/b/",
+                    "six.txt dir/b/",
+                    "editor dir/"
+                ]
+            );
+        });
+
+        // Select and confirm "File"
+        editor.update_in(&mut cx, |editor, window, cx| {
+            assert!(editor.has_visible_completions_menu());
+            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
+            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
+        });
+
+        cx.run_until_parked();
+
+        editor.update(&mut cx, |editor, cx| {
+            assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) ");
+        });
+    }
+
+    fn current_completion_labels(editor: &Editor) -> Vec<String> {
+        let completions = editor.current_completions().expect("Missing completions");
+        completions
+            .into_iter()
+            .map(|completion| completion.label.text.to_string())
+            .collect::<Vec<_>>()
+    }
+
+    pub(crate) fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            theme::init(theme::LoadThemes::JustBase, cx);
+            client::init_settings(cx);
+            language::init(cx);
+            Project::init_settings(cx);
+            workspace::init_settings(cx);
+            editor::init_settings(cx);
+        });
+    }
+}

crates/agent_ui/src/acp/message_history.rs πŸ”—

@@ -0,0 +1,81 @@
+pub struct MessageHistory<T> {
+    items: Vec<T>,
+    current: Option<usize>,
+}
+
+impl<T> MessageHistory<T> {
+    pub fn new() -> Self {
+        MessageHistory {
+            items: Vec::new(),
+            current: None,
+        }
+    }
+
+    pub fn push(&mut self, message: T) {
+        self.current.take();
+        self.items.push(message);
+    }
+
+    pub fn prev(&mut self) -> Option<&T> {
+        if self.items.is_empty() {
+            return None;
+        }
+
+        let new_ix = self
+            .current
+            .get_or_insert(self.items.len())
+            .saturating_sub(1);
+
+        self.current = Some(new_ix);
+        self.items.get(new_ix)
+    }
+
+    pub fn next(&mut self) -> Option<&T> {
+        let current = self.current.as_mut()?;
+        *current += 1;
+
+        self.items.get(*current).or_else(|| {
+            self.current.take();
+            None
+        })
+    }
+}
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_prev_next() {
+        let mut history = MessageHistory::new();
+
+        // Test empty history
+        assert_eq!(history.prev(), None);
+        assert_eq!(history.next(), None);
+
+        // Add some messages
+        history.push("first");
+        history.push("second");
+        history.push("third");
+
+        // Test prev navigation
+        assert_eq!(history.prev(), Some(&"third"));
+        assert_eq!(history.prev(), Some(&"second"));
+        assert_eq!(history.prev(), Some(&"first"));
+        assert_eq!(history.prev(), Some(&"first"));
+
+        assert_eq!(history.next(), Some(&"second"));
+
+        // Test mixed navigation
+        history.push("fourth");
+        assert_eq!(history.prev(), Some(&"fourth"));
+        assert_eq!(history.prev(), Some(&"third"));
+        assert_eq!(history.next(), Some(&"fourth"));
+        assert_eq!(history.next(), None);
+
+        // Test that push resets navigation
+        history.prev();
+        history.prev();
+        history.push("fifth");
+        assert_eq!(history.prev(), Some(&"fifth"));
+    }
+}

crates/agent_ui/src/acp/thread_view.rs πŸ”—

@@ -0,0 +1,1972 @@
+use std::path::Path;
+use std::rc::Rc;
+use std::sync::Arc;
+use std::time::Duration;
+
+use agentic_coding_protocol::{self as acp};
+use collections::{HashMap, HashSet};
+use editor::{
+    AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
+    EditorStyle, MinimapVisibility, MultiBuffer,
+};
+use file_icons::FileIcons;
+use futures::channel::oneshot;
+use gpui::{
+    Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, Focusable,
+    Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement, Subscription, TextStyle,
+    TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, div, list, percentage,
+    prelude::*, pulsating_between,
+};
+use gpui::{FocusHandle, Task};
+use language::language_settings::SoftWrap;
+use language::{Buffer, Language};
+use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
+use parking_lot::Mutex;
+use project::Project;
+use settings::Settings as _;
+use theme::ThemeSettings;
+use ui::{Disclosure, Tooltip, prelude::*};
+use util::ResultExt;
+use workspace::Workspace;
+use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
+
+use ::acp::{
+    AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff,
+    LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent,
+    ToolCallId, ToolCallStatus,
+};
+
+use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
+use crate::acp::message_history::MessageHistory;
+
+const RESPONSE_PADDING_X: Pixels = px(19.);
+
+pub struct AcpThreadView {
+    workspace: WeakEntity<Workspace>,
+    project: Entity<Project>,
+    thread_state: ThreadState,
+    diff_editors: HashMap<EntityId, Entity<Editor>>,
+    message_editor: Entity<Editor>,
+    mention_set: Arc<Mutex<MentionSet>>,
+    last_error: Option<Entity<Markdown>>,
+    list_state: ListState,
+    auth_task: Option<Task<()>>,
+    expanded_tool_calls: HashSet<ToolCallId>,
+    expanded_thinking_blocks: HashSet<(usize, usize)>,
+    message_history: MessageHistory<acp::UserMessage>,
+}
+
+enum ThreadState {
+    Loading {
+        _task: Task<()>,
+    },
+    Ready {
+        thread: Entity<AcpThread>,
+        _subscription: Subscription,
+    },
+    LoadError(LoadError),
+    Unauthenticated {
+        thread: Entity<AcpThread>,
+    },
+}
+
+impl AcpThreadView {
+    pub fn new(
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let language = Language::new(
+            language::LanguageConfig {
+                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
+                ..Default::default()
+            },
+            None,
+        );
+
+        let mention_set = Arc::new(Mutex::new(MentionSet::default()));
+
+        let message_editor = cx.new(|cx| {
+            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
+            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+
+            let mut editor = Editor::new(
+                editor::EditorMode::AutoHeight {
+                    min_lines: 4,
+                    max_lines: None,
+                },
+                buffer,
+                None,
+                window,
+                cx,
+            );
+            editor.set_placeholder_text("Message the agent - @ to include files", cx);
+            editor.set_show_indent_guides(false, cx);
+            editor.set_soft_wrap();
+            editor.set_use_modal_editing(true);
+            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
+                mention_set.clone(),
+                workspace.clone(),
+                cx.weak_entity(),
+            ))));
+            editor.set_context_menu_options(ContextMenuOptions {
+                min_entries_visible: 12,
+                max_entries_visible: 12,
+                placement: Some(ContextMenuPlacement::Above),
+            });
+            editor
+        });
+
+        let list_state = ListState::new(
+            0,
+            gpui::ListAlignment::Bottom,
+            px(2048.0),
+            cx.processor({
+                move |this: &mut Self, index: usize, window, cx| {
+                    let Some((entry, len)) = this.thread().and_then(|thread| {
+                        let entries = &thread.read(cx).entries();
+                        Some((entries.get(index)?, entries.len()))
+                    }) else {
+                        return Empty.into_any();
+                    };
+                    this.render_entry(index, len, entry, window, cx)
+                }
+            }),
+        );
+
+        Self {
+            workspace,
+            project: project.clone(),
+            thread_state: Self::initial_state(project, window, cx),
+            message_editor,
+            mention_set,
+            diff_editors: Default::default(),
+            list_state: list_state,
+            last_error: None,
+            auth_task: None,
+            expanded_tool_calls: HashSet::default(),
+            expanded_thinking_blocks: HashSet::default(),
+            message_history: MessageHistory::new(),
+        }
+    }
+
+    fn initial_state(
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> ThreadState {
+        let root_dir = project
+            .read(cx)
+            .visible_worktrees(cx)
+            .next()
+            .map(|worktree| worktree.read(cx).abs_path())
+            .unwrap_or_else(|| paths::home_dir().as_path().into());
+
+        let load_task = cx.spawn_in(window, async move |this, cx| {
+            let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await
+            {
+                Ok(thread) => thread,
+                Err(err) => {
+                    this.update(cx, |this, cx| {
+                        this.handle_load_error(err, cx);
+                        cx.notify();
+                    })
+                    .log_err();
+                    return;
+                }
+            };
+
+            let init_response = async {
+                let resp = thread
+                    .read_with(cx, |thread, _cx| thread.initialize())?
+                    .await?;
+                anyhow::Ok(resp)
+            };
+
+            let result = match init_response.await {
+                Err(e) => {
+                    let mut cx = cx.clone();
+                    if e.downcast_ref::<oneshot::Canceled>().is_some() {
+                        let child_status = thread
+                            .update(&mut cx, |thread, _| thread.child_status())
+                            .ok()
+                            .flatten();
+                        if let Some(child_status) = child_status {
+                            match child_status.await {
+                                Ok(_) => Err(e),
+                                Err(e) => Err(e),
+                            }
+                        } else {
+                            Err(e)
+                        }
+                    } else {
+                        Err(e)
+                    }
+                }
+                Ok(response) => {
+                    if !response.is_authenticated {
+                        this.update(cx, |this, _| {
+                            this.thread_state = ThreadState::Unauthenticated { thread };
+                        })
+                        .ok();
+                        return;
+                    };
+                    Ok(())
+                }
+            };
+
+            this.update_in(cx, |this, window, cx| {
+                match result {
+                    Ok(()) => {
+                        let subscription =
+                            cx.subscribe_in(&thread, window, Self::handle_thread_event);
+                        this.list_state
+                            .splice(0..0, thread.read(cx).entries().len());
+
+                        this.thread_state = ThreadState::Ready {
+                            thread,
+                            _subscription: subscription,
+                        };
+                        cx.notify();
+                    }
+                    Err(err) => {
+                        this.handle_load_error(err, cx);
+                    }
+                };
+            })
+            .log_err();
+        });
+
+        ThreadState::Loading { _task: load_task }
+    }
+
+    fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context<Self>) {
+        if let Some(load_err) = err.downcast_ref::<LoadError>() {
+            self.thread_state = ThreadState::LoadError(load_err.clone());
+        } else {
+            self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
+        }
+        cx.notify();
+    }
+
+    fn thread(&self) -> Option<&Entity<AcpThread>> {
+        match &self.thread_state {
+            ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
+                Some(thread)
+            }
+            ThreadState::Loading { .. } | ThreadState::LoadError(..) => None,
+        }
+    }
+
+    pub fn title(&self, cx: &App) -> SharedString {
+        match &self.thread_state {
+            ThreadState::Ready { thread, .. } => thread.read(cx).title(),
+            ThreadState::Loading { .. } => "Loading…".into(),
+            ThreadState::LoadError(_) => "Failed to load".into(),
+            ThreadState::Unauthenticated { .. } => "Not authenticated".into(),
+        }
+    }
+
+    pub fn cancel(&mut self, cx: &mut Context<Self>) {
+        self.last_error.take();
+
+        if let Some(thread) = self.thread() {
+            thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
+        }
+    }
+
+    fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
+        self.last_error.take();
+
+        let mut ix = 0;
+        let mut chunks: Vec<acp::UserMessageChunk> = Vec::new();
+
+        let project = self.project.clone();
+        self.message_editor.update(cx, |editor, cx| {
+            let text = editor.text(cx);
+            editor.display_map.update(cx, |map, cx| {
+                let snapshot = map.snapshot(cx);
+                for (crease_id, crease) in snapshot.crease_snapshot.creases() {
+                    if let Some(project_path) =
+                        self.mention_set.lock().path_for_crease_id(crease_id)
+                    {
+                        let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
+                        if crease_range.start > ix {
+                            chunks.push(acp::UserMessageChunk::Text {
+                                chunk: text[ix..crease_range.start].to_string(),
+                            });
+                        }
+                        if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) {
+                            chunks.push(acp::UserMessageChunk::Path { path: abs_path });
+                        }
+                        ix = crease_range.end;
+                    }
+                }
+
+                if ix < text.len() {
+                    let last_chunk = text[ix..].trim();
+                    if !last_chunk.is_empty() {
+                        chunks.push(last_chunk.into());
+                    }
+                }
+            })
+        });
+
+        if chunks.is_empty() {
+            return;
+        }
+
+        let Some(thread) = self.thread() else { return };
+        let message = acp::UserMessage { chunks };
+        let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx));
+
+        cx.spawn(async move |this, cx| {
+            let result = task.await;
+
+            this.update(cx, |this, cx| {
+                if let Err(err) = result {
+                    this.last_error =
+                        Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx)))
+                }
+            })
+        })
+        .detach();
+
+        let mention_set = self.mention_set.clone();
+
+        self.message_editor.update(cx, |editor, cx| {
+            editor.clear(window, cx);
+            editor.remove_creases(mention_set.lock().drain(), cx)
+        });
+
+        self.message_history.push(message);
+    }
+
+    fn previous_history_message(
+        &mut self,
+        _: &PreviousHistoryMessage,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        Self::set_draft_message(
+            self.message_editor.clone(),
+            self.mention_set.clone(),
+            self.project.clone(),
+            self.message_history.prev(),
+            window,
+            cx,
+        );
+    }
+
+    fn next_history_message(
+        &mut self,
+        _: &NextHistoryMessage,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        Self::set_draft_message(
+            self.message_editor.clone(),
+            self.mention_set.clone(),
+            self.project.clone(),
+            self.message_history.next(),
+            window,
+            cx,
+        );
+    }
+
+    fn set_draft_message(
+        message_editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+        project: Entity<Project>,
+        message: Option<&acp::UserMessage>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        cx.notify();
+
+        let Some(message) = message else {
+            message_editor.update(cx, |editor, cx| {
+                editor.clear(window, cx);
+                editor.remove_creases(mention_set.lock().drain(), cx)
+            });
+            return;
+        };
+
+        let mut text = String::new();
+        let mut mentions = Vec::new();
+
+        for chunk in &message.chunks {
+            match chunk {
+                acp::UserMessageChunk::Text { chunk } => {
+                    text.push_str(&chunk);
+                }
+                acp::UserMessageChunk::Path { path } => {
+                    let start = text.len();
+                    let content = MentionPath::new(path).to_string();
+                    text.push_str(&content);
+                    let end = text.len();
+                    if let Some(project_path) =
+                        project.read(cx).project_path_for_absolute_path(path, cx)
+                    {
+                        let filename: SharedString = path
+                            .file_name()
+                            .unwrap_or_default()
+                            .to_string_lossy()
+                            .to_string()
+                            .into();
+                        mentions.push((start..end, project_path, filename));
+                    }
+                }
+            }
+        }
+
+        let snapshot = message_editor.update(cx, |editor, cx| {
+            editor.set_text(text, window, cx);
+            editor.buffer().read(cx).snapshot(cx)
+        });
+
+        for (range, project_path, filename) in mentions {
+            let crease_icon_path = if project_path.path.is_dir() {
+                FileIcons::get_folder_icon(false, cx)
+                    .unwrap_or_else(|| IconName::Folder.path().into())
+            } else {
+                FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
+                    .unwrap_or_else(|| IconName::File.path().into())
+            };
+
+            let anchor = snapshot.anchor_before(range.start);
+            let crease_id = crate::context_picker::insert_crease_for_mention(
+                anchor.excerpt_id,
+                anchor.text_anchor,
+                range.end - range.start,
+                filename,
+                crease_icon_path,
+                message_editor.clone(),
+                window,
+                cx,
+            );
+            if let Some(crease_id) = crease_id {
+                mention_set.lock().insert(crease_id, project_path);
+            }
+        }
+    }
+
+    fn handle_thread_event(
+        &mut self,
+        thread: &Entity<AcpThread>,
+        event: &AcpThreadEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let count = self.list_state.item_count();
+        match event {
+            AcpThreadEvent::NewEntry => {
+                self.sync_thread_entry_view(thread.read(cx).entries().len() - 1, window, cx);
+                self.list_state.splice(count..count, 1);
+            }
+            AcpThreadEvent::EntryUpdated(index) => {
+                let index = *index;
+                self.sync_thread_entry_view(index, window, cx);
+                self.list_state.splice(index..index + 1, 1);
+            }
+        }
+        cx.notify();
+    }
+
+    fn sync_thread_entry_view(
+        &mut self,
+        entry_ix: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else {
+            return;
+        };
+
+        if self.diff_editors.contains_key(&multibuffer.entity_id()) {
+            return;
+        }
+
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::new(
+                EditorMode::Full {
+                    scale_ui_elements_with_buffer_font_size: false,
+                    show_active_line_background: false,
+                    sized_by_content: true,
+                },
+                multibuffer.clone(),
+                None,
+                window,
+                cx,
+            );
+            editor.set_show_gutter(false, cx);
+            editor.disable_inline_diagnostics();
+            editor.disable_expand_excerpt_buttons(cx);
+            editor.set_show_vertical_scrollbar(false, cx);
+            editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
+            editor.set_soft_wrap_mode(SoftWrap::None, cx);
+            editor.scroll_manager.set_forbid_vertical_scroll(true);
+            editor.set_show_indent_guides(false, cx);
+            editor.set_read_only(true);
+            editor.set_show_breakpoints(false, cx);
+            editor.set_show_code_actions(false, cx);
+            editor.set_show_git_diff_gutter(false, cx);
+            editor.set_expand_all_diff_hunks(cx);
+            editor.set_text_style_refinement(TextStyleRefinement {
+                font_size: Some(
+                    TextSize::Small
+                        .rems(cx)
+                        .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
+                        .into(),
+                ),
+                ..Default::default()
+            });
+            editor
+        });
+        let entity_id = multibuffer.entity_id();
+        cx.observe_release(&multibuffer, move |this, _, _| {
+            this.diff_editors.remove(&entity_id);
+        })
+        .detach();
+
+        self.diff_editors.insert(entity_id, editor);
+    }
+
+    fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
+        let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
+        if let AgentThreadEntry::ToolCall(ToolCall {
+            content: Some(ToolCallContent::Diff { diff }),
+            ..
+        }) = &entry
+        {
+            Some(diff.multibuffer.clone())
+        } else {
+            None
+        }
+    }
+
+    fn authenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(thread) = self.thread().cloned() else {
+            return;
+        };
+
+        self.last_error.take();
+        let authenticate = thread.read(cx).authenticate();
+        self.auth_task = Some(cx.spawn_in(window, {
+            let project = self.project.clone();
+            async move |this, cx| {
+                let result = authenticate.await;
+
+                this.update_in(cx, |this, window, cx| {
+                    if let Err(err) = result {
+                        this.last_error = Some(cx.new(|cx| {
+                            Markdown::new(format!("Error: {err}").into(), None, None, cx)
+                        }))
+                    } else {
+                        this.thread_state = Self::initial_state(project.clone(), window, cx)
+                    }
+                    this.auth_task.take()
+                })
+                .ok();
+            }
+        }));
+    }
+
+    fn authorize_tool_call(
+        &mut self,
+        id: ToolCallId,
+        outcome: acp::ToolCallConfirmationOutcome,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(thread) = self.thread() else {
+            return;
+        };
+        thread.update(cx, |thread, cx| {
+            thread.authorize_tool_call(id, outcome, cx);
+        });
+        cx.notify();
+    }
+
+    fn render_entry(
+        &self,
+        index: usize,
+        total_entries: usize,
+        entry: &AgentThreadEntry,
+        window: &mut Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        match &entry {
+            AgentThreadEntry::UserMessage(message) => div()
+                .py_4()
+                .px_2()
+                .child(
+                    v_flex()
+                        .p_3()
+                        .gap_1p5()
+                        .rounded_lg()
+                        .shadow_md()
+                        .bg(cx.theme().colors().editor_background)
+                        .border_1()
+                        .border_color(cx.theme().colors().border)
+                        .text_xs()
+                        .child(self.render_markdown(
+                            message.content.clone(),
+                            user_message_markdown_style(window, cx),
+                        )),
+                )
+                .into_any(),
+            AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
+                let style = default_markdown_style(false, window, cx);
+                let message_body = v_flex()
+                    .w_full()
+                    .gap_2p5()
+                    .children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| {
+                        match chunk {
+                            AssistantMessageChunk::Text { chunk } => self
+                                .render_markdown(chunk.clone(), style.clone())
+                                .into_any_element(),
+                            AssistantMessageChunk::Thought { chunk } => self.render_thinking_block(
+                                index,
+                                chunk_ix,
+                                chunk.clone(),
+                                window,
+                                cx,
+                            ),
+                        }
+                    }))
+                    .into_any();
+
+                v_flex()
+                    .px_5()
+                    .py_1()
+                    .when(index + 1 == total_entries, |this| this.pb_4())
+                    .w_full()
+                    .text_ui(cx)
+                    .child(message_body)
+                    .into_any()
+            }
+            AgentThreadEntry::ToolCall(tool_call) => div()
+                .py_1p5()
+                .px_5()
+                .child(self.render_tool_call(index, tool_call, window, cx))
+                .into_any(),
+        }
+    }
+
+    fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
+        cx.theme()
+            .colors()
+            .element_background
+            .blend(cx.theme().colors().editor_foreground.opacity(0.025))
+    }
+
+    fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
+        cx.theme().colors().border.opacity(0.6)
+    }
+
+    fn tool_name_font_size(&self) -> Rems {
+        rems_from_px(13.)
+    }
+
+    fn render_thinking_block(
+        &self,
+        entry_ix: usize,
+        chunk_ix: usize,
+        chunk: Entity<Markdown>,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
+        let key = (entry_ix, chunk_ix);
+        let is_open = self.expanded_thinking_blocks.contains(&key);
+
+        v_flex()
+            .child(
+                h_flex()
+                    .id(header_id)
+                    .group("disclosure-header")
+                    .w_full()
+                    .justify_between()
+                    .opacity(0.8)
+                    .hover(|style| style.opacity(1.))
+                    .child(
+                        h_flex()
+                            .gap_1p5()
+                            .child(
+                                Icon::new(IconName::ToolBulb)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(
+                                div()
+                                    .text_size(self.tool_name_font_size())
+                                    .child("Thinking"),
+                            ),
+                    )
+                    .child(
+                        div().visible_on_hover("disclosure-header").child(
+                            Disclosure::new("thinking-disclosure", is_open)
+                                .opened_icon(IconName::ChevronUp)
+                                .closed_icon(IconName::ChevronDown)
+                                .on_click(cx.listener({
+                                    move |this, _event, _window, cx| {
+                                        if is_open {
+                                            this.expanded_thinking_blocks.remove(&key);
+                                        } else {
+                                            this.expanded_thinking_blocks.insert(key);
+                                        }
+                                        cx.notify();
+                                    }
+                                })),
+                        ),
+                    )
+                    .on_click(cx.listener({
+                        move |this, _event, _window, cx| {
+                            if is_open {
+                                this.expanded_thinking_blocks.remove(&key);
+                            } else {
+                                this.expanded_thinking_blocks.insert(key);
+                            }
+                            cx.notify();
+                        }
+                    })),
+            )
+            .when(is_open, |this| {
+                this.child(
+                    div()
+                        .relative()
+                        .mt_1p5()
+                        .ml(px(7.))
+                        .pl_4()
+                        .border_l_1()
+                        .border_color(self.tool_card_border_color(cx))
+                        .text_ui_sm(cx)
+                        .child(
+                            self.render_markdown(chunk, default_markdown_style(false, window, cx)),
+                        ),
+                )
+            })
+            .into_any_element()
+    }
+
+    fn render_tool_call(
+        &self,
+        entry_ix: usize,
+        tool_call: &ToolCall,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> Div {
+        let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix));
+
+        let status_icon = match &tool_call.status {
+            ToolCallStatus::WaitingForConfirmation { .. } => None,
+            ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Running,
+                ..
+            } => Some(
+                Icon::new(IconName::ArrowCircle)
+                    .color(Color::Accent)
+                    .size(IconSize::Small)
+                    .with_animation(
+                        "running",
+                        Animation::new(Duration::from_secs(2)).repeat(),
+                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                    )
+                    .into_any(),
+            ),
+            ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Finished,
+                ..
+            } => None,
+            ToolCallStatus::Rejected
+            | ToolCallStatus::Canceled
+            | ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Error,
+                ..
+            } => Some(
+                Icon::new(IconName::X)
+                    .color(Color::Error)
+                    .size(IconSize::Small)
+                    .into_any_element(),
+            ),
+        };
+
+        let needs_confirmation = match &tool_call.status {
+            ToolCallStatus::WaitingForConfirmation { .. } => true,
+            _ => tool_call
+                .content
+                .iter()
+                .any(|content| matches!(content, ToolCallContent::Diff { .. })),
+        };
+
+        let is_collapsible = tool_call.content.is_some() && !needs_confirmation;
+        let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id);
+
+        let content = if is_open {
+            match &tool_call.status {
+                ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
+                    Some(self.render_tool_call_confirmation(
+                        tool_call.id,
+                        confirmation,
+                        tool_call.content.as_ref(),
+                        window,
+                        cx,
+                    ))
+                }
+                ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
+                    tool_call.content.as_ref().map(|content| {
+                        div()
+                            .py_1p5()
+                            .child(self.render_tool_call_content(content, window, cx))
+                            .into_any_element()
+                    })
+                }
+                ToolCallStatus::Rejected => None,
+            }
+        } else {
+            None
+        };
+
+        v_flex()
+            .when(needs_confirmation, |this| {
+                this.rounded_lg()
+                    .border_1()
+                    .border_color(self.tool_card_border_color(cx))
+                    .bg(cx.theme().colors().editor_background)
+                    .overflow_hidden()
+            })
+            .child(
+                h_flex()
+                    .id(header_id)
+                    .w_full()
+                    .gap_1()
+                    .justify_between()
+                    .map(|this| {
+                        if needs_confirmation {
+                            this.px_2()
+                                .py_1()
+                                .rounded_t_md()
+                                .bg(self.tool_card_header_bg(cx))
+                                .border_b_1()
+                                .border_color(self.tool_card_border_color(cx))
+                        } else {
+                            this.opacity(0.8).hover(|style| style.opacity(1.))
+                        }
+                    })
+                    .child(
+                        h_flex()
+                            .id("tool-call-header")
+                            .overflow_x_scroll()
+                            .map(|this| {
+                                if needs_confirmation {
+                                    this.text_xs()
+                                } else {
+                                    this.text_size(self.tool_name_font_size())
+                                }
+                            })
+                            .gap_1p5()
+                            .child(
+                                Icon::new(tool_call.icon)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(self.render_markdown(
+                                tool_call.label.clone(),
+                                default_markdown_style(needs_confirmation, window, cx),
+                            )),
+                    )
+                    .child(
+                        h_flex()
+                            .gap_0p5()
+                            .when(is_collapsible, |this| {
+                                this.child(
+                                    Disclosure::new(("expand", tool_call.id.0), is_open)
+                                        .opened_icon(IconName::ChevronUp)
+                                        .closed_icon(IconName::ChevronDown)
+                                        .on_click(cx.listener({
+                                            let id = tool_call.id;
+                                            move |this: &mut Self, _, _, cx: &mut Context<Self>| {
+                                                if is_open {
+                                                    this.expanded_tool_calls.remove(&id);
+                                                } else {
+                                                    this.expanded_tool_calls.insert(id);
+                                                }
+                                                cx.notify();
+                                            }
+                                        })),
+                                )
+                            })
+                            .children(status_icon),
+                    )
+                    .on_click(cx.listener({
+                        let id = tool_call.id;
+                        move |this: &mut Self, _, _, cx: &mut Context<Self>| {
+                            if is_open {
+                                this.expanded_tool_calls.remove(&id);
+                            } else {
+                                this.expanded_tool_calls.insert(id);
+                            }
+                            cx.notify();
+                        }
+                    })),
+            )
+            .when(is_open, |this| {
+                this.child(
+                    div()
+                        .text_xs()
+                        .when(is_collapsible, |this| {
+                            this.mt_1()
+                                .border_1()
+                                .border_color(self.tool_card_border_color(cx))
+                                .bg(cx.theme().colors().editor_background)
+                                .rounded_lg()
+                        })
+                        .children(content),
+                )
+            })
+    }
+
+    fn render_tool_call_content(
+        &self,
+        content: &ToolCallContent,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        match content {
+            ToolCallContent::Markdown { markdown } => self
+                .render_markdown(markdown.clone(), default_markdown_style(false, window, cx))
+                .into_any_element(),
+            ToolCallContent::Diff {
+                diff: Diff {
+                    path, multibuffer, ..
+                },
+                ..
+            } => self.render_diff_editor(multibuffer, path),
+        }
+    }
+
+    fn render_tool_call_confirmation(
+        &self,
+        tool_call_id: ToolCallId,
+        confirmation: &ToolCallConfirmation,
+        content: Option<&ToolCallContent>,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let confirmation_container = v_flex().mt_1().py_1p5();
+
+        let button_container = h_flex()
+            .pt_1p5()
+            .px_1p5()
+            .gap_1()
+            .justify_end()
+            .border_t_1()
+            .border_color(self.tool_card_border_color(cx));
+
+        match confirmation {
+            ToolCallConfirmation::Edit { description } => confirmation_container
+                .child(
+                    div()
+                        .px_2()
+                        .children(description.clone().map(|description| {
+                            self.render_markdown(
+                                description,
+                                default_markdown_style(false, window, cx),
+                            )
+                        })),
+                )
+                .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
+                .child(
+                    button_container
+                        .child(
+                            Button::new(("always_allow", tool_call_id.0), "Always Allow Edits")
+                                .icon(IconName::CheckDouble)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("allow", tool_call_id.0), "Allow")
+                                .icon(IconName::Check)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Allow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("reject", tool_call_id.0), "Reject")
+                                .icon(IconName::X)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Error)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Reject,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                )
+                .into_any(),
+            ToolCallConfirmation::Execute {
+                command,
+                root_command,
+                description,
+            } => confirmation_container
+                .child(v_flex().px_2().pb_1p5().child(command.clone()).children(
+                    description.clone().map(|description| {
+                        self.render_markdown(description, default_markdown_style(false, window, cx))
+                            .on_url_click({
+                                let workspace = self.workspace.clone();
+                                move |text, window, cx| {
+                                    Self::open_link(text, &workspace, window, cx);
+                                }
+                            })
+                    }),
+                ))
+                .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
+                .child(
+                    button_container
+                        .child(
+                            Button::new(
+                                ("always_allow", tool_call_id.0),
+                                format!("Always Allow {root_command}"),
+                            )
+                            .icon(IconName::CheckDouble)
+                            .icon_position(IconPosition::Start)
+                            .icon_size(IconSize::XSmall)
+                            .icon_color(Color::Success)
+                            .label_size(LabelSize::Small)
+                            .on_click(cx.listener({
+                                let id = tool_call_id;
+                                move |this, _, _, cx| {
+                                    this.authorize_tool_call(
+                                        id,
+                                        acp::ToolCallConfirmationOutcome::AlwaysAllow,
+                                        cx,
+                                    );
+                                }
+                            })),
+                        )
+                        .child(
+                            Button::new(("allow", tool_call_id.0), "Allow")
+                                .icon(IconName::Check)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Allow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("reject", tool_call_id.0), "Reject")
+                                .icon(IconName::X)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Error)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Reject,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                )
+                .into_any(),
+            ToolCallConfirmation::Mcp {
+                server_name,
+                tool_name: _,
+                tool_display_name,
+                description,
+            } => confirmation_container
+                .child(
+                    v_flex()
+                        .px_2()
+                        .pb_1p5()
+                        .child(format!("{server_name} - {tool_display_name}"))
+                        .children(description.clone().map(|description| {
+                            self.render_markdown(
+                                description,
+                                default_markdown_style(false, window, cx),
+                            )
+                        })),
+                )
+                .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
+                .child(
+                    button_container
+                        .child(
+                            Button::new(
+                                ("always_allow_server", tool_call_id.0),
+                                format!("Always Allow {server_name}"),
+                            )
+                            .icon(IconName::CheckDouble)
+                            .icon_position(IconPosition::Start)
+                            .icon_size(IconSize::XSmall)
+                            .icon_color(Color::Success)
+                            .label_size(LabelSize::Small)
+                            .on_click(cx.listener({
+                                let id = tool_call_id;
+                                move |this, _, _, cx| {
+                                    this.authorize_tool_call(
+                                        id,
+                                        acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
+                                        cx,
+                                    );
+                                }
+                            })),
+                        )
+                        .child(
+                            Button::new(
+                                ("always_allow_tool", tool_call_id.0),
+                                format!("Always Allow {tool_display_name}"),
+                            )
+                            .icon(IconName::CheckDouble)
+                            .icon_position(IconPosition::Start)
+                            .icon_size(IconSize::XSmall)
+                            .icon_color(Color::Success)
+                            .label_size(LabelSize::Small)
+                            .on_click(cx.listener({
+                                let id = tool_call_id;
+                                move |this, _, _, cx| {
+                                    this.authorize_tool_call(
+                                        id,
+                                        acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
+                                        cx,
+                                    );
+                                }
+                            })),
+                        )
+                        .child(
+                            Button::new(("allow", tool_call_id.0), "Allow")
+                                .icon(IconName::Check)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Allow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("reject", tool_call_id.0), "Reject")
+                                .icon(IconName::X)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Error)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Reject,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                )
+                .into_any(),
+            ToolCallConfirmation::Fetch { description, urls } => confirmation_container
+                .child(
+                    v_flex()
+                        .px_2()
+                        .pb_1p5()
+                        .gap_1()
+                        .children(urls.iter().map(|url| {
+                            h_flex().child(
+                                Button::new(url.clone(), url)
+                                    .icon(IconName::ArrowUpRight)
+                                    .icon_color(Color::Muted)
+                                    .icon_size(IconSize::XSmall)
+                                    .on_click({
+                                        let url = url.clone();
+                                        move |_, _, cx| cx.open_url(&url)
+                                    }),
+                            )
+                        }))
+                        .children(description.clone().map(|description| {
+                            self.render_markdown(
+                                description,
+                                default_markdown_style(false, window, cx),
+                            )
+                        })),
+                )
+                .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
+                .child(
+                    button_container
+                        .child(
+                            Button::new(("always_allow", tool_call_id.0), "Always Allow")
+                                .icon(IconName::CheckDouble)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("allow", tool_call_id.0), "Allow")
+                                .icon(IconName::Check)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Allow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("reject", tool_call_id.0), "Reject")
+                                .icon(IconName::X)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Error)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Reject,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                )
+                .into_any(),
+            ToolCallConfirmation::Other { description } => confirmation_container
+                .child(v_flex().px_2().pb_1p5().child(self.render_markdown(
+                    description.clone(),
+                    default_markdown_style(false, window, cx),
+                )))
+                .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
+                .child(
+                    button_container
+                        .child(
+                            Button::new(("always_allow", tool_call_id.0), "Always Allow")
+                                .icon(IconName::CheckDouble)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("allow", tool_call_id.0), "Allow")
+                                .icon(IconName::Check)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Success)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Allow,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        )
+                        .child(
+                            Button::new(("reject", tool_call_id.0), "Reject")
+                                .icon(IconName::X)
+                                .icon_position(IconPosition::Start)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Error)
+                                .label_size(LabelSize::Small)
+                                .on_click(cx.listener({
+                                    let id = tool_call_id;
+                                    move |this, _, _, cx| {
+                                        this.authorize_tool_call(
+                                            id,
+                                            acp::ToolCallConfirmationOutcome::Reject,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                )
+                .into_any(),
+        }
+    }
+
+    fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>, path: &Path) -> AnyElement {
+        v_flex()
+            .h_full()
+            .child(path.to_string_lossy().to_string())
+            .child(
+                if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) {
+                    editor.clone().into_any_element()
+                } else {
+                    Empty.into_any()
+                },
+            )
+            .into_any()
+    }
+
+    fn render_gemini_logo(&self) -> AnyElement {
+        Icon::new(IconName::AiGemini)
+            .color(Color::Muted)
+            .size(IconSize::XLarge)
+            .into_any_element()
+    }
+
+    fn render_error_gemini_logo(&self) -> AnyElement {
+        let logo = Icon::new(IconName::AiGemini)
+            .color(Color::Muted)
+            .size(IconSize::XLarge)
+            .into_any_element();
+
+        h_flex()
+            .relative()
+            .justify_center()
+            .child(div().opacity(0.3).child(logo))
+            .child(
+                h_flex().absolute().right_1().bottom_0().child(
+                    Icon::new(IconName::XCircle)
+                        .color(Color::Error)
+                        .size(IconSize::Small),
+                ),
+            )
+            .into_any_element()
+    }
+
+    fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement {
+        v_flex()
+            .size_full()
+            .items_center()
+            .justify_center()
+            .child(
+                if loading {
+                    h_flex()
+                        .justify_center()
+                        .child(self.render_gemini_logo())
+                        .with_animation(
+                            "pulsating_icon",
+                            Animation::new(Duration::from_secs(2))
+                                .repeat()
+                                .with_easing(pulsating_between(0.4, 1.0)),
+                            |icon, delta| icon.opacity(delta),
+                        ).into_any()
+                } else {
+                    self.render_gemini_logo().into_any_element()
+                }
+            )
+            .child(
+                h_flex()
+                    .mt_4()
+                    .mb_1()
+                    .justify_center()
+                    .child(Headline::new(if loading {
+                        "Connecting to Gemini…"
+                    } else {
+                        "Welcome to Gemini"
+                    }).size(HeadlineSize::Medium)),
+            )
+            .child(
+                div()
+                    .max_w_1_2()
+                    .text_sm()
+                    .text_center()
+                    .map(|this| if loading {
+                        this.invisible()
+                    } else {
+                        this.text_color(cx.theme().colors().text_muted)
+                    })
+                    .child("Ask questions, edit files, run commands.\nBe specific for the best results.")
+            )
+            .into_any()
+    }
+
+    fn render_pending_auth_state(&self) -> AnyElement {
+        v_flex()
+            .items_center()
+            .justify_center()
+            .child(self.render_error_gemini_logo())
+            .child(
+                h_flex()
+                    .mt_4()
+                    .mb_1()
+                    .justify_center()
+                    .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
+            )
+            .into_any()
+    }
+
+    fn render_error_state(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
+        let mut container = v_flex()
+            .items_center()
+            .justify_center()
+            .child(self.render_error_gemini_logo())
+            .child(
+                v_flex()
+                    .mt_4()
+                    .mb_2()
+                    .gap_0p5()
+                    .text_center()
+                    .items_center()
+                    .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
+                    .child(
+                        Label::new(e.to_string())
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    ),
+            );
+
+        if matches!(e, LoadError::Unsupported { .. }) {
+            container =
+                container.child(Button::new("upgrade", "Upgrade Gemini to Latest").on_click(
+                    cx.listener(|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 command =
+                                    "npm install -g @google/gemini-cli@latest".to_string();
+                                let spawn_in_terminal = task::SpawnInTerminal {
+                                    id: task::TaskId("install".to_string()),
+                                    full_label: command.clone(),
+                                    label: command.clone(),
+                                    command: Some(command.clone()),
+                                    args: Vec::new(),
+                                    command_label: 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();
+                            })
+                            .ok();
+                    }),
+                ));
+        }
+
+        container.into_any()
+    }
+
+    fn render_message_editor(&mut self, cx: &mut Context<Self>) -> AnyElement {
+        let settings = ThemeSettings::get_global(cx);
+        let font_size = TextSize::Small
+            .rems(cx)
+            .to_pixels(settings.agent_font_size(cx));
+        let line_height = settings.buffer_line_height.value() * font_size;
+
+        let text_style = TextStyle {
+            color: cx.theme().colors().text,
+            font_family: settings.buffer_font.family.clone(),
+            font_fallbacks: settings.buffer_font.fallbacks.clone(),
+            font_features: settings.buffer_font.features.clone(),
+            font_size: font_size.into(),
+            line_height: line_height.into(),
+            ..Default::default()
+        };
+
+        EditorElement::new(
+            &self.message_editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                syntax: cx.theme().syntax().clone(),
+                ..Default::default()
+            },
+        )
+        .into_any()
+    }
+
+    fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
+        let workspace = self.workspace.clone();
+        MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
+            Self::open_link(text, &workspace, window, cx);
+        })
+    }
+
+    fn open_link(
+        url: SharedString,
+        workspace: &WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let Some(workspace) = workspace.upgrade() else {
+            cx.open_url(&url);
+            return;
+        };
+
+        if let Some(mention_path) = MentionPath::try_parse(&url) {
+            workspace.update(cx, |workspace, cx| {
+                let project = workspace.project();
+                let Some((path, entry)) = project.update(cx, |project, cx| {
+                    let path = project.find_project_path(mention_path.path(), cx)?;
+                    let entry = project.entry_for_path(&path, cx)?;
+                    Some((path, entry))
+                }) else {
+                    return;
+                };
+
+                if entry.is_dir() {
+                    project.update(cx, |_, cx| {
+                        cx.emit(project::Event::RevealInProjectPanel(entry.id));
+                    });
+                } else {
+                    workspace
+                        .open_path(path, None, true, window, cx)
+                        .detach_and_log_err(cx);
+                }
+            })
+        } else {
+            cx.open_url(&url);
+        }
+    }
+
+    pub fn open_thread_as_markdown(
+        &self,
+        workspace: Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<anyhow::Result<()>> {
+        let markdown_language_task = workspace
+            .read(cx)
+            .app_state()
+            .languages
+            .language_for_name("Markdown");
+
+        let (thread_summary, markdown) = match &self.thread_state {
+            ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
+                let thread = thread.read(cx);
+                (thread.title().to_string(), thread.to_markdown(cx))
+            }
+            ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())),
+        };
+
+        window.spawn(cx, async move |cx| {
+            let markdown_language = markdown_language_task.await?;
+
+            workspace.update_in(cx, |workspace, window, cx| {
+                let project = workspace.project().clone();
+
+                if !project.read(cx).is_local() {
+                    anyhow::bail!("failed to open active thread as markdown in remote project");
+                }
+
+                let buffer = project.update(cx, |project, cx| {
+                    project.create_local_buffer(&markdown, Some(markdown_language), cx)
+                });
+                let buffer = cx.new(|cx| {
+                    MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
+                });
+
+                workspace.add_item_to_active_pane(
+                    Box::new(cx.new(|cx| {
+                        let mut editor =
+                            Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
+                        editor.set_breadcrumb_header(thread_summary);
+                        editor
+                    })),
+                    None,
+                    true,
+                    window,
+                    cx,
+                );
+
+                anyhow::Ok(())
+            })??;
+            anyhow::Ok(())
+        })
+    }
+
+    fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
+        self.list_state.scroll_to(ListOffset::default());
+        cx.notify();
+    }
+}
+
+impl Focusable for AcpThreadView {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.message_editor.focus_handle(cx)
+    }
+}
+
+impl Render for AcpThreadView {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let text = self.message_editor.read(cx).text(cx);
+        let is_editor_empty = text.is_empty();
+        let focus_handle = self.message_editor.focus_handle(cx);
+
+        let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText)
+            .icon_size(IconSize::XSmall)
+            .icon_color(Color::Ignored)
+            .tooltip(Tooltip::text("Open Thread as Markdown"))
+            .on_click(cx.listener(move |this, _, window, cx| {
+                if let Some(workspace) = this.workspace.upgrade() {
+                    this.open_thread_as_markdown(workspace, window, cx)
+                        .detach_and_log_err(cx);
+                }
+            }));
+
+        let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt)
+            .icon_size(IconSize::XSmall)
+            .icon_color(Color::Ignored)
+            .tooltip(Tooltip::text("Scroll To Top"))
+            .on_click(cx.listener(move |this, _, _, cx| {
+                this.scroll_to_top(cx);
+            }));
+
+        let feedback_container = h_flex()
+            .group("feedback_container")
+            .mt_1()
+            .py_2()
+            .px(RESPONSE_PADDING_X)
+            .mr_1()
+            .opacity(0.4)
+            .hover(|style| style.opacity(1.))
+            .gap_1p5()
+            .flex_wrap()
+            .justify_end()
+            .child(h_flex().child(open_as_markdown))
+            .child(scroll_to_top)
+            .into_any_element();
+
+        let show_controls = matches!(&self.thread_state, ThreadState::Ready { thread, .. } if thread.read(cx).status() == ThreadStatus::Idle);
+
+        v_flex()
+            .size_full()
+            .key_context("AcpThread")
+            .on_action(cx.listener(Self::chat))
+            .on_action(cx.listener(Self::previous_history_message))
+            .on_action(cx.listener(Self::next_history_message))
+            .child(match &self.thread_state {
+                ThreadState::Unauthenticated { .. } => v_flex()
+                    .p_2()
+                    .flex_1()
+                    .items_center()
+                    .justify_center()
+                    .child(self.render_pending_auth_state())
+                    .child(h_flex().mt_1p5().justify_center().child(
+                        Button::new("sign-in", "Sign in to Gemini").on_click(
+                            cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
+                        ),
+                    )),
+                ThreadState::Loading { .. } => {
+                    v_flex().flex_1().child(self.render_empty_state(true, cx))
+                }
+                ThreadState::LoadError(e) => v_flex()
+                    .p_2()
+                    .flex_1()
+                    .items_center()
+                    .justify_center()
+                    .child(self.render_error_state(e, cx)),
+                ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| {
+                    if self.list_state.item_count() > 0 {
+                        this.child(
+                            list(self.list_state.clone())
+                                .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
+                                .flex_grow()
+                                .into_any(),
+                        )
+                        .children(match thread.read(cx).status() {
+                            ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None,
+                            ThreadStatus::Generating => div()
+                                .px_5()
+                                .py_2()
+                                .child(LoadingLabel::new("").size(LabelSize::Small))
+                                .into(),
+                        })
+                    } else {
+                        this.child(self.render_empty_state(false, cx))
+                    }
+                }),
+            })
+            .when(show_controls, |el| el.child(feedback_container))
+            .when_some(self.last_error.clone(), |el, error| {
+                el.child(
+                    div()
+                        .p_2()
+                        .text_xs()
+                        .border_t_1()
+                        .border_color(cx.theme().colors().border)
+                        .bg(cx.theme().status().error_background)
+                        .child(
+                            self.render_markdown(error, default_markdown_style(false, window, cx)),
+                        ),
+                )
+            })
+            .child(
+                v_flex()
+                    .p_2()
+                    .pt_3()
+                    .gap_1()
+                    .bg(cx.theme().colors().editor_background)
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border)
+                    .child(self.render_message_editor(cx))
+                    .child({
+                        let thread = self.thread();
+
+                        h_flex().justify_end().child(
+                            if thread.map_or(true, |thread| {
+                                thread.read(cx).status() == ThreadStatus::Idle
+                            }) {
+                                IconButton::new("send-message", IconName::Send)
+                                    .icon_color(Color::Accent)
+                                    .style(ButtonStyle::Filled)
+                                    .disabled(thread.is_none() || is_editor_empty)
+                                    .on_click({
+                                        let focus_handle = focus_handle.clone();
+                                        move |_event, window, cx| {
+                                            focus_handle.dispatch_action(&Chat, window, cx);
+                                        }
+                                    })
+                                    .when(!is_editor_empty, |button| {
+                                        button.tooltip(move |window, cx| {
+                                            Tooltip::for_action("Send", &Chat, window, cx)
+                                        })
+                                    })
+                                    .when(is_editor_empty, |button| {
+                                        button.tooltip(Tooltip::text("Type a message to submit"))
+                                    })
+                            } else {
+                                IconButton::new("stop-generation", IconName::StopFilled)
+                                    .icon_color(Color::Error)
+                                    .style(ButtonStyle::Tinted(ui::TintColor::Error))
+                                    .tooltip(move |window, cx| {
+                                        Tooltip::for_action(
+                                            "Stop Generation",
+                                            &editor::actions::Cancel,
+                                            window,
+                                            cx,
+                                        )
+                                    })
+                                    .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
+                            },
+                        )
+                    }),
+            )
+    }
+}
+
+fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
+    let mut style = default_markdown_style(false, window, cx);
+    let mut text_style = window.text_style();
+    let theme_settings = ThemeSettings::get_global(cx);
+
+    let buffer_font = theme_settings.buffer_font.family.clone();
+    let buffer_font_size = TextSize::Small.rems(cx);
+
+    text_style.refine(&TextStyleRefinement {
+        font_family: Some(buffer_font),
+        font_size: Some(buffer_font_size.into()),
+        ..Default::default()
+    });
+
+    style.base_text_style = text_style;
+    style.link_callback = Some(Rc::new(move |url, cx| {
+        if MentionPath::try_parse(url).is_some() {
+            let colors = cx.theme().colors();
+            Some(TextStyleRefinement {
+                background_color: Some(colors.element_background),
+                ..Default::default()
+            })
+        } else {
+            None
+        }
+    }));
+    style
+}
+
+fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
+    let theme_settings = ThemeSettings::get_global(cx);
+    let colors = cx.theme().colors();
+
+    let buffer_font_size = TextSize::Small.rems(cx);
+
+    let mut text_style = window.text_style();
+    let line_height = buffer_font_size * 1.75;
+
+    let font_family = if buffer_font {
+        theme_settings.buffer_font.family.clone()
+    } else {
+        theme_settings.ui_font.family.clone()
+    };
+
+    let font_size = if buffer_font {
+        TextSize::Small.rems(cx)
+    } else {
+        TextSize::Default.rems(cx)
+    };
+
+    text_style.refine(&TextStyleRefinement {
+        font_family: Some(font_family),
+        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
+        font_features: Some(theme_settings.ui_font.features.clone()),
+        font_size: Some(font_size.into()),
+        line_height: Some(line_height.into()),
+        color: Some(cx.theme().colors().text),
+        ..Default::default()
+    });
+
+    MarkdownStyle {
+        base_text_style: text_style.clone(),
+        syntax: cx.theme().syntax().clone(),
+        selection_background_color: cx.theme().colors().element_selection_background,
+        code_block_overflow_x_scroll: true,
+        table_overflow_x_scroll: true,
+        heading_level_styles: Some(HeadingLevelStyles {
+            h1: Some(TextStyleRefinement {
+                font_size: Some(rems(1.15).into()),
+                ..Default::default()
+            }),
+            h2: Some(TextStyleRefinement {
+                font_size: Some(rems(1.1).into()),
+                ..Default::default()
+            }),
+            h3: Some(TextStyleRefinement {
+                font_size: Some(rems(1.05).into()),
+                ..Default::default()
+            }),
+            h4: Some(TextStyleRefinement {
+                font_size: Some(rems(1.).into()),
+                ..Default::default()
+            }),
+            h5: Some(TextStyleRefinement {
+                font_size: Some(rems(0.95).into()),
+                ..Default::default()
+            }),
+            h6: Some(TextStyleRefinement {
+                font_size: Some(rems(0.875).into()),
+                ..Default::default()
+            }),
+        }),
+        code_block: StyleRefinement {
+            padding: EdgesRefinement {
+                top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+                left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+                right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+                bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+            },
+            margin: EdgesRefinement {
+                top: Some(Length::Definite(Pixels(8.).into())),
+                left: Some(Length::Definite(Pixels(0.).into())),
+                right: Some(Length::Definite(Pixels(0.).into())),
+                bottom: Some(Length::Definite(Pixels(12.).into())),
+            },
+            border_style: Some(BorderStyle::Solid),
+            border_widths: EdgesRefinement {
+                top: Some(AbsoluteLength::Pixels(Pixels(1.))),
+                left: Some(AbsoluteLength::Pixels(Pixels(1.))),
+                right: Some(AbsoluteLength::Pixels(Pixels(1.))),
+                bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
+            },
+            border_color: Some(colors.border_variant),
+            background: Some(colors.editor_background.into()),
+            text: Some(TextStyleRefinement {
+                font_family: Some(theme_settings.buffer_font.family.clone()),
+                font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
+                font_features: Some(theme_settings.buffer_font.features.clone()),
+                font_size: Some(buffer_font_size.into()),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        inline_code: TextStyleRefinement {
+            font_family: Some(theme_settings.buffer_font.family.clone()),
+            font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
+            font_features: Some(theme_settings.buffer_font.features.clone()),
+            font_size: Some(buffer_font_size.into()),
+            background_color: Some(colors.editor_foreground.opacity(0.08)),
+            ..Default::default()
+        },
+        link: TextStyleRefinement {
+            background_color: Some(colors.editor_foreground.opacity(0.025)),
+            underline: Some(UnderlineStyle {
+                color: Some(colors.text_accent.opacity(0.5)),
+                thickness: px(1.),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        ..Default::default()
+    }
+}

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -7,12 +7,14 @@ use std::time::Duration;
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
 use serde::{Deserialize, Serialize};
 
+use crate::NewGeminiThread;
 use crate::language_model_selector::ToggleModelSelector;
 use crate::{
     AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
     DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
     NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
     ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
+    acp::AcpThreadView,
     active_thread::{self, ActiveThread, ActiveThreadEvent},
     agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
     agent_diff::AgentDiff,
@@ -38,6 +40,7 @@ use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_tool::ToolWorkingSet;
 use client::{UserStore, zed_urls};
 use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
+use feature_flags::{self, FeatureFlagAppExt};
 use fs::Fs;
 use gpui::{
     Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
@@ -109,6 +112,12 @@ pub fn init(cx: &mut App) {
                         panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx));
                     }
                 })
+                .register_action(|workspace, _: &NewGeminiThread, window, cx| {
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        workspace.focus_panel::<AgentPanel>(window, cx);
+                        panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx));
+                    }
+                })
                 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
                     if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                         workspace.focus_panel::<AgentPanel>(window, cx);
@@ -125,7 +134,8 @@ pub fn init(cx: &mut App) {
                                 let thread = thread.read(cx).thread().clone();
                                 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
                             }
-                            ActiveView::TextThread { .. }
+                            ActiveView::AcpThread { .. }
+                            | ActiveView::TextThread { .. }
                             | ActiveView::History
                             | ActiveView::Configuration => {}
                         }
@@ -188,6 +198,9 @@ enum ActiveView {
         message_editor: Entity<MessageEditor>,
         _subscriptions: Vec<gpui::Subscription>,
     },
+    AcpThread {
+        thread_view: Entity<AcpThreadView>,
+    },
     TextThread {
         context_editor: Entity<TextThreadEditor>,
         title_editor: Entity<Editor>,
@@ -207,7 +220,9 @@ enum WhichFontSize {
 impl ActiveView {
     pub fn which_font_size_used(&self) -> WhichFontSize {
         match self {
-            ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont,
+            ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => {
+                WhichFontSize::AgentFont
+            }
             ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
             ActiveView::Configuration => WhichFontSize::None,
         }
@@ -238,6 +253,7 @@ impl ActiveView {
                             thread.scroll_to_bottom(cx);
                         });
                     }
+                    ActiveView::AcpThread { .. } => {}
                     ActiveView::TextThread { .. }
                     | ActiveView::History
                     | ActiveView::Configuration => {}
@@ -653,7 +669,8 @@ impl AgentPanel {
                             .clone()
                             .update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
                     }
-                    ActiveView::TextThread { .. }
+                    ActiveView::AcpThread { .. }
+                    | ActiveView::TextThread { .. }
                     | ActiveView::History
                     | ActiveView::Configuration => {}
                 },
@@ -733,6 +750,9 @@ impl AgentPanel {
             ActiveView::Thread { thread, .. } => {
                 thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
             }
+            ActiveView::AcpThread { thread_view, .. } => {
+                thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx));
+            }
             ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
         }
     }
@@ -740,7 +760,10 @@ impl AgentPanel {
     fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> {
         match &self.active_view {
             ActiveView::Thread { message_editor, .. } => Some(message_editor),
-            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
+            ActiveView::AcpThread { .. }
+            | ActiveView::TextThread { .. }
+            | ActiveView::History
+            | ActiveView::Configuration => None,
         }
     }
 
@@ -862,6 +885,21 @@ impl AgentPanel {
         context_editor.focus_handle(cx).focus(window);
     }
 
+    fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let workspace = self.workspace.clone();
+        let project = self.project.clone();
+
+        cx.spawn_in(window, async move |this, cx| {
+            let thread_view = cx.new_window_entity(|window, cx| {
+                crate::acp::AcpThreadView::new(workspace, project, window, cx)
+            })?;
+            this.update_in(cx, |this, window, cx| {
+                this.set_active_view(ActiveView::AcpThread { thread_view }, window, cx);
+            })
+        })
+        .detach();
+    }
+
     fn deploy_rules_library(
         &mut self,
         action: &OpenRulesLibrary,
@@ -994,6 +1032,7 @@ impl AgentPanel {
                 cx,
             )
         });
+
         let message_editor = cx.new(|cx| {
             MessageEditor::new(
                 self.fs.clone(),
@@ -1025,6 +1064,9 @@ impl AgentPanel {
                         ActiveView::Thread { message_editor, .. } => {
                             message_editor.focus_handle(cx).focus(window);
                         }
+                        ActiveView::AcpThread { thread_view } => {
+                            thread_view.focus_handle(cx).focus(window);
+                        }
                         ActiveView::TextThread { context_editor, .. } => {
                             context_editor.focus_handle(cx).focus(window);
                         }
@@ -1144,7 +1186,10 @@ impl AgentPanel {
                     })
                     .log_err();
             }
-            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
+            ActiveView::AcpThread { .. }
+            | ActiveView::TextThread { .. }
+            | ActiveView::History
+            | ActiveView::Configuration => {}
         }
     }
 
@@ -1197,6 +1242,13 @@ impl AgentPanel {
                 )
                 .detach_and_log_err(cx);
             }
+            ActiveView::AcpThread { thread_view } => {
+                thread_view
+                    .update(cx, |thread_view, cx| {
+                        thread_view.open_thread_as_markdown(workspace, window, cx)
+                    })
+                    .detach_and_log_err(cx);
+            }
             ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
         }
     }
@@ -1351,7 +1403,8 @@ impl AgentPanel {
                     }
                 })
             }
-            _ => {}
+            ActiveView::AcpThread { .. } => {}
+            ActiveView::History | ActiveView::Configuration => {}
         }
 
         if current_is_special && !new_is_special {
@@ -1437,6 +1490,7 @@ impl Focusable for AgentPanel {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         match &self.active_view {
             ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx),
+            ActiveView::AcpThread { thread_view, .. } => thread_view.focus_handle(cx),
             ActiveView::History => self.history.focus_handle(cx),
             ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
             ActiveView::Configuration => {
@@ -1593,6 +1647,9 @@ impl AgentPanel {
                         .into_any_element(),
                 }
             }
+            ActiveView::AcpThread { thread_view } => Label::new(thread_view.read(cx).title(cx))
+                .truncate()
+                .into_any_element(),
             ActiveView::TextThread {
                 title_editor,
                 context_editor,
@@ -1727,7 +1784,10 @@ impl AgentPanel {
 
         let active_thread = match &self.active_view {
             ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
-            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
+            ActiveView::AcpThread { .. }
+            | ActiveView::TextThread { .. }
+            | ActiveView::History
+            | ActiveView::Configuration => None,
         };
 
         let agent_extra_menu = PopoverMenu::new("agent-options-menu")
@@ -1755,6 +1815,9 @@ impl AgentPanel {
                     menu = menu
                         .action("New Thread", NewThread::default().boxed_clone())
                         .action("New Text Thread", NewTextThread.boxed_clone())
+                        .when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
+                            this.action("New Gemini Thread", NewGeminiThread.boxed_clone())
+                        })
                         .when_some(active_thread, |this, active_thread| {
                             let thread = active_thread.read(cx);
                             if !thread.is_empty() {
@@ -1893,6 +1956,9 @@ impl AgentPanel {
                 message_editor,
                 ..
             } => (thread.read(cx), message_editor.read(cx)),
+            ActiveView::AcpThread { .. } => {
+                return None;
+            }
             ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
                 return None;
             }
@@ -2031,6 +2097,9 @@ impl AgentPanel {
                     return false;
                 }
             }
+            ActiveView::AcpThread { .. } => {
+                return false;
+            }
             ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
                 return false;
             }
@@ -2615,6 +2684,9 @@ impl AgentPanel {
     ) -> Option<AnyElement> {
         let active_thread = match &self.active_view {
             ActiveView::Thread { thread, .. } => thread,
+            ActiveView::AcpThread { .. } => {
+                return None;
+            }
             ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
                 return None;
             }
@@ -2961,6 +3033,9 @@ impl AgentPanel {
                     .detach();
                 });
             }
+            ActiveView::AcpThread { .. } => {
+                unimplemented!()
+            }
             ActiveView::TextThread { context_editor, .. } => {
                 context_editor.update(cx, |context_editor, cx| {
                     TextThreadEditor::insert_dragged_files(
@@ -3034,6 +3109,7 @@ impl Render for AgentPanel {
                         });
                         this.continue_conversation(window, cx);
                     }
+                    ActiveView::AcpThread { .. } => {}
                     ActiveView::TextThread { .. }
                     | ActiveView::History
                     | ActiveView::Configuration => {}
@@ -3075,6 +3151,10 @@ impl Render for AgentPanel {
                     })
                     .child(h_flex().child(message_editor.clone()))
                     .child(self.render_drag_target(cx)),
+                ActiveView::AcpThread { thread_view, .. } => parent
+                    .relative()
+                    .child(thread_view.clone())
+                    .child(self.render_drag_target(cx)),
                 ActiveView::History => parent.child(self.history.clone()),
                 ActiveView::TextThread {
                     context_editor,

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

@@ -1,3 +1,4 @@
+mod acp;
 mod active_thread;
 mod agent_configuration;
 mod agent_diff;
@@ -56,6 +57,8 @@ actions!(
     [
         /// Creates a new text-based conversation thread.
         NewTextThread,
+        /// Creates a new Gemini CLI-based conversation thread.
+        NewGeminiThread,
         /// Toggles the context picker interface for adding files, symbols, or other context.
         ToggleContextPicker,
         /// Toggles the navigation menu for switching between threads and views.
@@ -76,8 +79,6 @@ actions!(
         AddContextServer,
         /// Removes the currently selected thread.
         RemoveSelectedThread,
-        /// Starts a chat conversation with the agent.
-        Chat,
         /// Starts a chat conversation with follow-up enabled.
         ChatWithFollow,
         /// Cycles to the next inline assist suggestion.

crates/agent_ui/src/context_picker.rs πŸ”—

@@ -1,6 +1,6 @@
 mod completion_provider;
 mod fetch_context_picker;
-mod file_context_picker;
+pub(crate) mod file_context_picker;
 mod rules_context_picker;
 mod symbol_context_picker;
 mod thread_context_picker;

crates/agent_ui/src/message_editor.rs πŸ”—

@@ -47,13 +47,14 @@ use ui::{
 };
 use util::ResultExt as _;
 use workspace::{CollaboratorId, Workspace};
+use zed_actions::agent::Chat;
 use zed_llm_client::CompletionIntent;
 
 use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
 use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 use crate::profile_selector::ProfileSelector;
 use crate::{
-    ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
+    ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
     ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
     ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
 };

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

@@ -92,6 +92,12 @@ impl FeatureFlag for JjUiFeatureFlag {
     const NAME: &'static str = "jj-ui";
 }
 
+pub struct AcpFeatureFlag;
+
+impl FeatureFlag for AcpFeatureFlag {
+    const NAME: &'static str = "acp";
+}
+
 pub struct ZedCloudFeatureFlag {}
 
 impl FeatureFlag for ZedCloudFeatureFlag {

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

@@ -13,6 +13,7 @@ pub enum IconName {
     AiBedrock,
     AiDeepSeek,
     AiEdit,
+    AiGemini,
     AiGoogle,
     AiLmStudio,
     AiMistral,
@@ -252,6 +253,14 @@ pub enum IconName {
     TextSnippet,
     ThumbsDown,
     ThumbsUp,
+    ToolBulb,
+    ToolFolder,
+    ToolHammer,
+    ToolPencil,
+    ToolRegex,
+    ToolSearch,
+    ToolTerminal,
+    ToolWeb,
     Trash,
     TrashAlt,
     Triangle,

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

@@ -352,6 +352,14 @@ pub fn debug_adapters_dir() -> &'static PathBuf {
     DEBUG_ADAPTERS_DIR.get_or_init(|| data_dir().join("debug_adapters"))
 }
 
+/// Returns the path to the agent servers directory
+///
+/// This is where agent servers are downloaded to
+pub fn agent_servers_dir() -> &'static PathBuf {
+    static AGENT_SERVERS_DIR: OnceLock<PathBuf> = OnceLock::new();
+    AGENT_SERVERS_DIR.get_or_init(|| data_dir().join("agent_servers"))
+}
+
 /// Returns the path to the Copilot directory.
 pub fn copilot_dir() -> &'static PathBuf {
     static COPILOT_DIR: OnceLock<PathBuf> = OnceLock::new();

crates/project/src/environment.rs πŸ”—

@@ -84,7 +84,7 @@ impl ProjectEnvironment {
         self.get_worktree_environment(worktree, cx)
     }
 
-    pub(crate) fn get_worktree_environment(
+    pub fn get_worktree_environment(
         &mut self,
         worktree: Entity<Worktree>,
         cx: &mut Context<Self>,
@@ -118,7 +118,7 @@ impl ProjectEnvironment {
     /// If the project was opened from the CLI, then the inherited CLI environment is returned.
     /// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in
     /// that directory, to get environment variables as if the user has `cd`'d there.
-    pub(crate) fn get_directory_environment(
+    pub fn get_directory_environment(
         &mut self,
         abs_path: Arc<Path>,
         cx: &mut Context<Self>,

crates/ui/src/traits/styled_ext.rs πŸ”—

@@ -39,7 +39,7 @@ pub trait StyledExt: Styled + Sized {
     /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
     ///
     /// Example Elements: Title Bar, Panel, Tab Bar, Editor
-    fn elevation_1(self, cx: &mut App) -> Self {
+    fn elevation_1(self, cx: &App) -> Self {
         elevated(self, cx, ElevationIndex::Surface)
     }
 

crates/vim/src/test/vim_test_context.rs πŸ”—

@@ -1,13 +1,11 @@
 use std::ops::{Deref, DerefMut};
 
 use editor::test::editor_lsp_test_context::EditorLspTestContext;
-use gpui::{Context, Entity, SemanticVersion, UpdateGlobal, actions};
+use gpui::{Context, Entity, SemanticVersion, UpdateGlobal};
 use search::{BufferSearchBar, project_search::ProjectSearchBar};
 
 use crate::{state::Operator, *};
 
-actions!(agent, [Chat]);
-
 pub struct VimTestContext {
     cx: EditorLspTestContext,
 }

crates/zed/Cargo.toml πŸ”—

@@ -23,6 +23,7 @@ activity_indicator.workspace = true
 agent.workspace = true
 agent_ui.workspace = true
 agent_settings.workspace = true
+agent_servers.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 assets.workspace = true

crates/zed/src/main.rs πŸ”—

@@ -520,6 +520,7 @@ pub fn main() {
         supermaven::init(app_state.client.clone(), cx);
         language_model::init(app_state.client.clone(), cx);
         language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
+        agent_servers::init(cx);
         web_search::init(cx);
         web_search_providers::init(app_state.client.clone(), cx);
         snippet_provider::init(cx);

crates/zed_actions/src/lib.rs πŸ”—

@@ -268,7 +268,13 @@ pub mod agent {
             /// Opens the agent onboarding modal.
             OpenOnboardingModal,
             /// Resets the agent onboarding state.
-            ResetOnboarding
+            ResetOnboarding,
+            /// Starts a chat conversation with the agent.
+            Chat,
+            /// Displays the previous message in the history.
+            PreviousHistoryMessage,
+            /// Displays the next message in the history.
+            NextHistoryMessage
         ]
     );
 }

tooling/workspace-hack/Cargo.toml πŸ”—

@@ -107,6 +107,7 @@ rustc-hash = { version = "1" }
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", default-features = false, features = ["fs", "net", "std"] }
 rustls = { version = "0.23", features = ["ring"] }
 rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] }
+schemars = { version = "1", features = ["chrono04", "indexmap2"] }
 sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] }
 sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] }
 semver = { version = "1", features = ["serde"] }
@@ -239,6 +240,7 @@ rustc-hash = { version = "1" }
 rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", default-features = false, features = ["fs", "net", "std"] }
 rustls = { version = "0.23", features = ["ring"] }
 rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] }
+schemars = { version = "1", features = ["chrono04", "indexmap2"] }
 sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] }
 sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] }
 semver = { version = "1", features = ["serde"] }