Detailed changes
@@ -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",
@@ -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"
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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": {
@@ -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": {
@@ -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,
@@ -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
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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()
+ }
+ }
+}
@@ -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
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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) {}
+}
@@ -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
@@ -0,0 +1,5 @@
+mod completion_provider;
+mod message_history;
+mod thread_view;
+
+pub use thread_view::AcpThreadView;
@@ -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);
+ });
+ }
+}
@@ -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"));
+ }
+}
@@ -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()
+ }
+}
@@ -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,
@@ -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.
@@ -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;
@@ -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,
};
@@ -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 {
@@ -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,
@@ -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();
@@ -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>,
@@ -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)
}
@@ -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,
}
@@ -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
@@ -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);
@@ -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
]
);
}
@@ -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"] }