Compare commits
64 Commits
assistant_
...
acp3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405f7cf64f | ||
|
|
73ac553316 | ||
|
|
136423da94 | ||
|
|
28baedd935 | ||
|
|
756358b9c7 | ||
|
|
54040188bb | ||
|
|
4755d6fa9d | ||
|
|
135143d51b | ||
|
|
450604b4a1 | ||
|
|
348bc52a3f | ||
|
|
d16c595d57 | ||
|
|
975a7e6f7f | ||
|
|
7d2f7cb70e | ||
|
|
5f9afdf7ba | ||
|
|
7a3105b0c6 | ||
|
|
ab0b16939d | ||
|
|
28d992487d | ||
|
|
fde15a5a68 | ||
|
|
780db30e0b | ||
|
|
7c992adfe1 | ||
|
|
825aecfd28 | ||
|
|
f2f32fb3bd | ||
|
|
d9fd8d5eee | ||
|
|
8137b3318f | ||
|
|
3ceeefe460 | ||
|
|
6f768aefa2 | ||
|
|
28ac84ed01 | ||
|
|
4d803fa628 | ||
|
|
17b2dd9a93 | ||
|
|
7abf635e20 | ||
|
|
92adcb6e63 | ||
|
|
5ed001e0df | ||
|
|
f12fffd1ba | ||
|
|
991ba08711 | ||
|
|
c728731099 | ||
|
|
ddab1cbd71 | ||
|
|
f383a7626f | ||
|
|
ee1df65569 | ||
|
|
3be45822be | ||
|
|
3b6f30a6fd | ||
|
|
779a68f868 | ||
|
|
79c37284e0 | ||
|
|
0a053cf55d | ||
|
|
fc59d9cbf3 | ||
|
|
678a42e920 | ||
|
|
75bcaf743c | ||
|
|
47c875f6b5 | ||
|
|
81b4d7e35a | ||
|
|
33ee0c3093 | ||
|
|
d68f86052f | ||
|
|
a74ffd9ee4 | ||
|
|
8b9ad1cfae | ||
|
|
adbccb1ad0 | ||
|
|
f4e2d38c29 | ||
|
|
5f10be7791 | ||
|
|
d47a920c05 | ||
|
|
24b72be154 | ||
|
|
de779a45ce | ||
|
|
b094a636cf | ||
|
|
318709b60d | ||
|
|
f1bd531a32 | ||
|
|
549eb4d826 | ||
|
|
c1e53b7fa5 | ||
|
|
ec376e0b61 |
49
Cargo.lock
generated
49
Cargo.lock
generated
@@ -2,6 +2,38 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agentic-coding-protocol",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"buffer_diff",
|
||||
"chrono",
|
||||
"collections",
|
||||
"editor",
|
||||
"env_logger 0.11.8",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"language",
|
||||
"log",
|
||||
"markdown",
|
||||
"parking_lot",
|
||||
"project",
|
||||
"proto",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"theme",
|
||||
"ui",
|
||||
"util",
|
||||
"uuid",
|
||||
"workspace-hack",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "activity_indicator"
|
||||
version = "0.1.0"
|
||||
@@ -130,6 +162,7 @@ dependencies = [
|
||||
name = "agent_ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acp",
|
||||
"agent",
|
||||
"agent_settings",
|
||||
"anyhow",
|
||||
@@ -212,6 +245,21 @@ dependencies = [
|
||||
"zed_llm_client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agentic-coding-protocol"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures 0.3.31",
|
||||
"log",
|
||||
"parking_lot",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@@ -14056,6 +14104,7 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"indexmap",
|
||||
"ref-cast",
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/activity_indicator",
|
||||
"crates/acp",
|
||||
"crates/agent_ui",
|
||||
"crates/agent",
|
||||
"crates/agent_settings",
|
||||
@@ -215,8 +216,9 @@ 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" }
|
||||
ai = { path = "crates/ai" }
|
||||
@@ -398,6 +400,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
||||
# External crates
|
||||
#
|
||||
|
||||
agentic-coding-protocol = { path = "../agentic-coding-protocol" }
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
@@ -480,7 +483,7 @@ json_dotpath = "1.1"
|
||||
jsonschema = "0.30.0"
|
||||
jsonwebtoken = "9.3"
|
||||
jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
libc = "0.2"
|
||||
libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
|
||||
linkify = "0.10.0"
|
||||
@@ -491,7 +494,7 @@ metal = "0.29"
|
||||
moka = { version = "0.12.10", features = ["sync"] }
|
||||
naga = { version = "25.0", features = ["wgsl-in"] }
|
||||
nanoid = "0.4"
|
||||
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
nix = "0.29"
|
||||
num-format = "0.4.4"
|
||||
objc = "0.2"
|
||||
@@ -531,7 +534,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c77
|
||||
"stream",
|
||||
] }
|
||||
rsa = "0.9.6"
|
||||
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
|
||||
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
|
||||
"async-dispatcher-runtime",
|
||||
] }
|
||||
rust-embed = { version = "8.4", features = ["include-exclude"] }
|
||||
|
||||
50
crates/acp/Cargo.toml
Normal file
50
crates/acp/Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[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"]
|
||||
|
||||
[dependencies]
|
||||
agentic-coding-protocol = { path = "../../../agentic-coding-protocol" }
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
buffer_diff.workspace = true
|
||||
chrono.workspace = true
|
||||
collections.workspace = true
|
||||
editor.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
markdown.workspace = true
|
||||
parking_lot.workspace = true
|
||||
project.workspace = true
|
||||
proto.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
zed_actions.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
gpui = { workspace = true, "features" = ["test-support"] }
|
||||
project = { workspace = true, "features" = ["test-support"] }
|
||||
serde_json.workspace = true
|
||||
util.workspace = true
|
||||
settings.workspace = true
|
||||
1
crates/acp/LICENSE-GPL
Symbolic link
1
crates/acp/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
748
crates/acp/src/acp.rs
Normal file
748
crates/acp/src/acp.rs
Normal file
@@ -0,0 +1,748 @@
|
||||
mod server;
|
||||
mod thread_view;
|
||||
|
||||
use agentic_coding_protocol::{self as acp, Role};
|
||||
use anyhow::{Context as _, Result};
|
||||
use buffer_diff::BufferDiff;
|
||||
use chrono::{DateTime, Utc};
|
||||
use editor::MultiBuffer;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
|
||||
use language::{Buffer, LanguageRegistry};
|
||||
use markdown::Markdown;
|
||||
use project::Project;
|
||||
use std::{mem, ops::Range, path::PathBuf, sync::Arc};
|
||||
use ui::{App, IconName};
|
||||
use util::{ResultExt, debug_panic};
|
||||
|
||||
pub use server::AcpServer;
|
||||
pub use thread_view::AcpThreadView;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ThreadId(SharedString);
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FileVersion(u64);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AgentThreadSummary {
|
||||
pub id: ThreadId,
|
||||
pub title: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FileContent {
|
||||
pub path: PathBuf,
|
||||
pub version: FileVersion,
|
||||
pub content: SharedString,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Message {
|
||||
pub role: acp::Role,
|
||||
pub chunks: Vec<MessageChunk>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
fn into_acp(self, cx: &App) -> acp::Message {
|
||||
acp::Message {
|
||||
role: self.role,
|
||||
chunks: self
|
||||
.chunks
|
||||
.into_iter()
|
||||
.map(|chunk| chunk.into_acp(cx))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MessageChunk {
|
||||
Text {
|
||||
chunk: Entity<Markdown>,
|
||||
},
|
||||
File {
|
||||
content: FileContent,
|
||||
},
|
||||
Directory {
|
||||
path: PathBuf,
|
||||
contents: Vec<FileContent>,
|
||||
},
|
||||
Symbol {
|
||||
path: PathBuf,
|
||||
range: Range<u64>,
|
||||
version: FileVersion,
|
||||
name: SharedString,
|
||||
content: SharedString,
|
||||
},
|
||||
Fetch {
|
||||
url: SharedString,
|
||||
content: SharedString,
|
||||
},
|
||||
}
|
||||
|
||||
impl MessageChunk {
|
||||
pub fn from_acp(
|
||||
chunk: acp::MessageChunk,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
match chunk {
|
||||
acp::MessageChunk::Text { chunk } => MessageChunk::Text {
|
||||
chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_acp(self, cx: &App) -> acp::MessageChunk {
|
||||
match self {
|
||||
MessageChunk::Text { chunk } => acp::MessageChunk::Text {
|
||||
chunk: chunk.read(cx).source().to_string(),
|
||||
},
|
||||
MessageChunk::File { .. } => todo!(),
|
||||
MessageChunk::Directory { .. } => todo!(),
|
||||
MessageChunk::Symbol { .. } => todo!(),
|
||||
MessageChunk::Fetch { .. } => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
|
||||
MessageChunk::Text {
|
||||
chunk: cx.new(|cx| {
|
||||
Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AgentThreadEntryContent {
|
||||
Message(Message),
|
||||
ToolCall(ToolCall),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolCall {
|
||||
id: ToolCallId,
|
||||
label: Entity<Markdown>,
|
||||
icon: IconName,
|
||||
status: ToolCallStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ToolCallStatus {
|
||||
WaitingForConfirmation {
|
||||
confirmation: acp::ToolCallConfirmation,
|
||||
respond_tx: oneshot::Sender<acp::ToolCallConfirmationOutcome>,
|
||||
},
|
||||
Allowed {
|
||||
status: acp::ToolCallStatus,
|
||||
content: Option<ToolCallContent>,
|
||||
},
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ToolCallContent {
|
||||
Markdown {
|
||||
markdown: Entity<Markdown>,
|
||||
},
|
||||
Diff {
|
||||
path: PathBuf,
|
||||
diff: Entity<BufferDiff>,
|
||||
buffer: Entity<MultiBuffer>,
|
||||
_task: Task<Result<()>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A `ThreadEntryId` that is known to be a ToolCall
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ToolCallId(ThreadEntryId);
|
||||
|
||||
impl ToolCallId {
|
||||
pub fn as_u64(&self) -> u64 {
|
||||
self.0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ThreadEntryId(pub u64);
|
||||
|
||||
impl ThreadEntryId {
|
||||
pub fn post_inc(&mut self) -> Self {
|
||||
let id = *self;
|
||||
self.0 += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ThreadEntry {
|
||||
pub id: ThreadEntryId,
|
||||
pub content: AgentThreadEntryContent,
|
||||
}
|
||||
|
||||
pub struct AcpThread {
|
||||
id: ThreadId,
|
||||
next_entry_id: ThreadEntryId,
|
||||
entries: Vec<ThreadEntry>,
|
||||
server: Arc<AcpServer>,
|
||||
title: SharedString,
|
||||
project: Entity<Project>,
|
||||
}
|
||||
|
||||
enum AcpThreadEvent {
|
||||
NewEntry,
|
||||
EntryUpdated(usize),
|
||||
}
|
||||
|
||||
impl EventEmitter<AcpThreadEvent> for AcpThread {}
|
||||
|
||||
impl AcpThread {
|
||||
pub fn new(
|
||||
server: Arc<AcpServer>,
|
||||
thread_id: ThreadId,
|
||||
entries: Vec<AgentThreadEntryContent>,
|
||||
project: Entity<Project>,
|
||||
_: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut next_entry_id = ThreadEntryId(0);
|
||||
Self {
|
||||
title: "A new agent2 thread".into(),
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|entry| ThreadEntry {
|
||||
id: next_entry_id.post_inc(),
|
||||
content: entry,
|
||||
})
|
||||
.collect(),
|
||||
server,
|
||||
id: thread_id,
|
||||
next_entry_id,
|
||||
project,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> SharedString {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[ThreadEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn push_entry(
|
||||
&mut self,
|
||||
entry: AgentThreadEntryContent,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ThreadEntryId {
|
||||
let id = self.next_entry_id.post_inc();
|
||||
self.entries.push(ThreadEntry { id, content: entry });
|
||||
cx.emit(AcpThreadEvent::NewEntry);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context<Self>) {
|
||||
let entries_len = self.entries.len();
|
||||
if let Some(last_entry) = self.entries.last_mut()
|
||||
&& let AgentThreadEntryContent::Message(Message {
|
||||
ref mut chunks,
|
||||
role: Role::Assistant,
|
||||
}) = last_entry.content
|
||||
{
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
|
||||
|
||||
if let (
|
||||
Some(MessageChunk::Text { chunk: old_chunk }),
|
||||
acp::MessageChunk::Text { chunk: new_chunk },
|
||||
) = (chunks.last_mut(), &chunk)
|
||||
{
|
||||
old_chunk.update(cx, |old_chunk, cx| {
|
||||
old_chunk.append(&new_chunk, cx);
|
||||
});
|
||||
} else {
|
||||
chunks.push(MessageChunk::from_acp(
|
||||
chunk,
|
||||
self.project.read(cx).languages().clone(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk = MessageChunk::from_acp(chunk, self.project.read(cx).languages().clone(), cx);
|
||||
|
||||
self.push_entry(
|
||||
AgentThreadEntryContent::Message(Message {
|
||||
role: Role::Assistant,
|
||||
chunks: vec![chunk],
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn request_tool_call(
|
||||
&mut self,
|
||||
label: String,
|
||||
icon: acp::Icon,
|
||||
confirmation: acp::ToolCallConfirmation,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolCallRequest {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
let status = ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation,
|
||||
respond_tx: tx,
|
||||
};
|
||||
|
||||
let id = self.insert_tool_call(label, status, icon, cx);
|
||||
ToolCallRequest { id, outcome: rx }
|
||||
}
|
||||
|
||||
pub fn push_tool_call(
|
||||
&mut self,
|
||||
label: String,
|
||||
icon: acp::Icon,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolCallId {
|
||||
let status = ToolCallStatus::Allowed {
|
||||
status: acp::ToolCallStatus::Running,
|
||||
content: None,
|
||||
};
|
||||
|
||||
self.insert_tool_call(label, status, icon, cx)
|
||||
}
|
||||
|
||||
fn insert_tool_call(
|
||||
&mut self,
|
||||
label: String,
|
||||
status: ToolCallStatus,
|
||||
icon: acp::Icon,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolCallId {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
|
||||
let entry_id = self.push_entry(
|
||||
AgentThreadEntryContent::ToolCall(ToolCall {
|
||||
// todo! clean up id creation
|
||||
id: ToolCallId(ThreadEntryId(self.entries.len() as u64)),
|
||||
label: cx.new(|cx| {
|
||||
Markdown::new(label.into(), Some(language_registry.clone()), None, cx)
|
||||
}),
|
||||
icon: acp_icon_to_ui_icon(icon),
|
||||
status,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
|
||||
ToolCallId(entry_id)
|
||||
}
|
||||
|
||||
pub fn authorize_tool_call(
|
||||
&mut self,
|
||||
id: ToolCallId,
|
||||
outcome: acp::ToolCallConfirmationOutcome,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(entry) = self.entry_mut(id.0) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let AgentThreadEntryContent::ToolCall(call) = &mut entry.content else {
|
||||
debug_panic!("expected ToolCall");
|
||||
return;
|
||||
};
|
||||
|
||||
let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject {
|
||||
ToolCallStatus::Rejected
|
||||
} else {
|
||||
ToolCallStatus::Allowed {
|
||||
status: acp::ToolCallStatus::Running,
|
||||
content: None,
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
debug_panic!("tried to authorize an already authorized tool call");
|
||||
}
|
||||
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize));
|
||||
}
|
||||
|
||||
pub fn update_tool_call(
|
||||
&mut self,
|
||||
id: 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 entry = self.entry_mut(id.0).context("Entry not found")?;
|
||||
|
||||
match &mut entry.content {
|
||||
AgentThreadEntryContent::ToolCall(call) => match &mut call.status {
|
||||
ToolCallStatus::Allowed { content, status } => {
|
||||
*content = new_content.map(|new_content| match new_content {
|
||||
acp::ToolCallContent::Markdown { markdown } => ToolCallContent::Markdown {
|
||||
markdown: cx.new(|cx| {
|
||||
Markdown::new(
|
||||
markdown.into(),
|
||||
Some(language_registry.clone()),
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
},
|
||||
acp::ToolCallContent::Diff {
|
||||
path,
|
||||
old_text,
|
||||
new_text,
|
||||
} => {
|
||||
let buffer = cx.new(|cx| Buffer::local(new_text, cx));
|
||||
let text_snapshot = buffer.read(cx).text_snapshot();
|
||||
let buffer_diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
|
||||
|
||||
let multibuffer = cx.new(|cx| {
|
||||
let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
|
||||
multibuffer.add_diff(buffer_diff.clone(), cx);
|
||||
multibuffer
|
||||
});
|
||||
|
||||
ToolCallContent::Diff {
|
||||
path: path.clone(),
|
||||
diff: buffer_diff.clone(),
|
||||
buffer: multibuffer,
|
||||
_task: cx.spawn(async move |_this, cx| {
|
||||
let diff_snapshot = BufferDiff::update_diff(
|
||||
buffer_diff.clone(),
|
||||
text_snapshot.clone(),
|
||||
old_text.map(|o| o.into()),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
Some(language_registry.clone()),
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
buffer_diff.update(cx, |diff, cx| {
|
||||
diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
|
||||
})?;
|
||||
|
||||
if let Some(language) = language_registry
|
||||
.language_for_file_path(&path)
|
||||
.await
|
||||
.log_err()
|
||||
{
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.set_language(Some(language), cx)
|
||||
})?;
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
}),
|
||||
}
|
||||
}
|
||||
});
|
||||
*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")
|
||||
}
|
||||
},
|
||||
_ => anyhow::bail!("Entry is not a tool call"),
|
||||
}
|
||||
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entry_mut(&mut self, id: ThreadEntryId) -> Option<&mut ThreadEntry> {
|
||||
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"
|
||||
);
|
||||
entry
|
||||
}
|
||||
|
||||
/// 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.content {
|
||||
AgentThreadEntryContent::ToolCall(call) => match call.status {
|
||||
ToolCallStatus::WaitingForConfirmation { .. } => return true,
|
||||
ToolCallStatus::Allowed { .. } | ToolCallStatus::Rejected => continue,
|
||||
},
|
||||
AgentThreadEntryContent::Message(_) => {
|
||||
// Reached the beginning of the turn
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn send(&mut self, message: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let agent = self.server.clone();
|
||||
let id = self.id.clone();
|
||||
let chunk = MessageChunk::from_str(message, self.project.read(cx).languages().clone(), cx);
|
||||
let message = Message {
|
||||
role: Role::User,
|
||||
chunks: vec![chunk],
|
||||
};
|
||||
self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx);
|
||||
let acp_message = message.into_acp(cx);
|
||||
cx.spawn(async move |_, cx| {
|
||||
agent.send_message(id, acp_message, cx).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName {
|
||||
match icon {
|
||||
acp::Icon::FileSearch => IconName::FileSearch,
|
||||
acp::Icon::Folder => IconName::Folder,
|
||||
acp::Icon::Globe => IconName::Globe,
|
||||
acp::Icon::Hammer => IconName::Hammer,
|
||||
acp::Icon::LightBulb => IconName::LightBulb,
|
||||
acp::Icon::Pencil => IconName::Pencil,
|
||||
acp::Icon::Regex => IconName::Regex,
|
||||
acp::Icon::Terminal => IconName::Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ToolCallRequest {
|
||||
pub id: ToolCallId,
|
||||
pub outcome: oneshot::Receiver<acp::ToolCallConfirmationOutcome>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::{FutureExt as _, channel::mpsc, select};
|
||||
use gpui::{AsyncApp, TestAppContext};
|
||||
use project::FakeFs;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use smol::stream::StreamExt as _;
|
||||
use std::{env, path::Path, process::Stdio, 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_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 server = gemini_acp_server(project.clone(), cx.to_async()).unwrap();
|
||||
let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
|
||||
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].content,
|
||||
AgentThreadEntryContent::Message(Message {
|
||||
role: Role::User,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
thread.entries[1].content,
|
||||
AgentThreadEntryContent::Message(Message {
|
||||
role: Role::Assistant,
|
||||
..
|
||||
})
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
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 server = gemini_acp_server(project.clone(), cx.to_async()).unwrap();
|
||||
let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
|
||||
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()[1].content,
|
||||
AgentThreadEntryContent::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
thread.entries[2].content,
|
||||
AgentThreadEntryContent::Message(Message {
|
||||
role: Role::Assistant,
|
||||
..
|
||||
})
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
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 server = gemini_acp_server(project.clone(), cx.to_async()).unwrap();
|
||||
let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send(r#"Run `echo "Hello, world!"`"#, cx)
|
||||
});
|
||||
|
||||
run_until_tool_call(&thread, cx).await;
|
||||
|
||||
let tool_call_id = thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntryContent::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: acp::ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[1].content
|
||||
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()[1].content,
|
||||
AgentThreadEntryContent::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
});
|
||||
|
||||
full_turn.await.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
let AgentThreadEntryContent::ToolCall(ToolCall {
|
||||
status:
|
||||
ToolCallStatus::Allowed {
|
||||
content: Some(ToolCallContent::Markdown { markdown }),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[1].content
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
markdown.read_with(cx, |md, _cx| {
|
||||
assert!(
|
||||
md.source().contains("Hello, world!"),
|
||||
r#"Expected '{}' to contain "Hello, world!""#,
|
||||
md.source()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_until_tool_call(thread: &Entity<AcpThread>, cx: &mut TestAppContext) {
|
||||
let (mut tx, mut rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let subscription = cx.update(|cx| {
|
||||
cx.subscribe(thread, move |thread, _, cx| {
|
||||
if thread
|
||||
.read(cx)
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| matches!(e.content, AgentThreadEntryContent::ToolCall(_)))
|
||||
{
|
||||
tx.try_send(()).unwrap();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
select! {
|
||||
_ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => {
|
||||
panic!("Timeout waiting for tool call")
|
||||
}
|
||||
_ = rx.next().fuse() => {
|
||||
drop(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gemini_acp_server(project: Entity<Project>, mut cx: AsyncApp) -> Result<Arc<AcpServer>> {
|
||||
let cli_path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
|
||||
let mut command = util::command::new_smol_command("node");
|
||||
command
|
||||
.arg(cli_path)
|
||||
.arg("--acp")
|
||||
.current_dir("/private/tmp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
|
||||
if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") {
|
||||
command.env("GEMINI_API_KEY", gemini_key);
|
||||
}
|
||||
|
||||
let child = command.spawn().unwrap();
|
||||
|
||||
Ok(AcpServer::stdio(child, project, &mut cx))
|
||||
}
|
||||
}
|
||||
363
crates/acp/src/server.rs
Normal file
363
crates/acp/src/server.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use crate::{AcpThread, ThreadEntryId, ThreadId, ToolCallId, ToolCallRequest};
|
||||
use agentic_coding_protocol as acp;
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_trait::async_trait;
|
||||
use collections::HashMap;
|
||||
use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity};
|
||||
use parking_lot::Mutex;
|
||||
use project::Project;
|
||||
use smol::process::Child;
|
||||
use std::{io::Write as _, path::Path, process::ExitStatus, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
pub struct AcpServer {
|
||||
connection: Arc<acp::AgentConnection>,
|
||||
threads: Arc<Mutex<HashMap<ThreadId, WeakEntity<AcpThread>>>>,
|
||||
project: Entity<Project>,
|
||||
exit_status: Arc<Mutex<Option<ExitStatus>>>,
|
||||
_handler_task: Task<()>,
|
||||
_io_task: Task<()>,
|
||||
}
|
||||
|
||||
struct AcpClientDelegate {
|
||||
project: Entity<Project>,
|
||||
threads: Arc<Mutex<HashMap<ThreadId, WeakEntity<AcpThread>>>>,
|
||||
cx: AsyncApp,
|
||||
// sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
|
||||
}
|
||||
|
||||
impl AcpClientDelegate {
|
||||
fn new(
|
||||
project: Entity<Project>,
|
||||
threads: Arc<Mutex<HashMap<ThreadId, WeakEntity<AcpThread>>>>,
|
||||
cx: AsyncApp,
|
||||
) -> Self {
|
||||
Self {
|
||||
project,
|
||||
threads,
|
||||
cx: cx,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_thread<R>(
|
||||
&self,
|
||||
thread_id: &ThreadId,
|
||||
cx: &mut App,
|
||||
callback: impl FnOnce(&mut AcpThread, &mut Context<AcpThread>) -> R,
|
||||
) -> Option<R> {
|
||||
let thread = self.threads.lock().get(&thread_id)?.clone();
|
||||
let Some(thread) = thread.upgrade() else {
|
||||
self.threads.lock().remove(&thread_id);
|
||||
return None;
|
||||
};
|
||||
Some(thread.update(cx, callback))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl acp::Client for AcpClientDelegate {
|
||||
async fn stat(&self, params: acp::StatParams) -> Result<acp::StatResponse> {
|
||||
let cx = &mut self.cx.clone();
|
||||
self.project.update(cx, |project, cx| {
|
||||
let path = project
|
||||
.project_path_for_absolute_path(Path::new(¶ms.path), cx)
|
||||
.context("Failed to get project path")?;
|
||||
|
||||
match project.entry_for_path(&path, cx) {
|
||||
// todo! refresh entry?
|
||||
None => Ok(acp::StatResponse {
|
||||
exists: false,
|
||||
is_directory: false,
|
||||
}),
|
||||
Some(entry) => Ok(acp::StatResponse {
|
||||
exists: entry.is_created(),
|
||||
is_directory: entry.is_dir(),
|
||||
}),
|
||||
}
|
||||
})?
|
||||
}
|
||||
|
||||
async fn stream_message_chunk(
|
||||
&self,
|
||||
params: acp::StreamMessageChunkParams,
|
||||
) -> Result<acp::StreamMessageChunkResponse> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
cx.update(|cx| {
|
||||
self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| {
|
||||
thread.push_assistant_chunk(params.chunk, cx)
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(acp::StreamMessageChunkResponse)
|
||||
}
|
||||
|
||||
async fn read_text_file(
|
||||
&self,
|
||||
request: acp::ReadTextFileParams,
|
||||
) -> Result<acp::ReadTextFileResponse> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let buffer = self
|
||||
.project
|
||||
.update(cx, |project, cx| {
|
||||
let path = project
|
||||
.project_path_for_absolute_path(Path::new(&request.path), cx)
|
||||
.context("Failed to get project path")?;
|
||||
anyhow::Ok(project.open_buffer(path, cx))
|
||||
})??
|
||||
.await?;
|
||||
|
||||
buffer.update(cx, |buffer, _cx| {
|
||||
let start = language::Point::new(request.line_offset.unwrap_or(0), 0);
|
||||
let end = match request.line_limit {
|
||||
None => buffer.max_point(),
|
||||
Some(limit) => start + language::Point::new(limit + 1, 0),
|
||||
};
|
||||
|
||||
let content: String = buffer.text_for_range(start..end).collect();
|
||||
|
||||
acp::ReadTextFileResponse {
|
||||
content,
|
||||
version: acp::FileVersion(0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_binary_file(
|
||||
&self,
|
||||
request: acp::ReadBinaryFileParams,
|
||||
) -> Result<acp::ReadBinaryFileResponse> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let file = self
|
||||
.project
|
||||
.update(cx, |project, cx| {
|
||||
let (worktree, path) = project
|
||||
.find_worktree(Path::new(&request.path), cx)
|
||||
.context("Failed to get project path")?;
|
||||
|
||||
let task = worktree.update(cx, |worktree, cx| worktree.load_binary_file(&path, cx));
|
||||
anyhow::Ok(task)
|
||||
})??
|
||||
.await?;
|
||||
|
||||
// todo! test
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let start = request.byte_offset.unwrap_or(0) as usize;
|
||||
let end = request
|
||||
.byte_limit
|
||||
.map(|limit| (start + limit as usize).min(file.content.len()))
|
||||
.unwrap_or(file.content.len());
|
||||
|
||||
let range_content = &file.content[start..end];
|
||||
|
||||
let mut base64_content = Vec::new();
|
||||
let mut base64_encoder = base64::write::EncoderWriter::new(
|
||||
std::io::Cursor::new(&mut base64_content),
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
);
|
||||
base64_encoder.write_all(range_content)?;
|
||||
drop(base64_encoder);
|
||||
|
||||
// SAFETY: The base64 encoder should not produce non-UTF8.
|
||||
unsafe { anyhow::Ok(String::from_utf8_unchecked(base64_content)) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(acp::ReadBinaryFileResponse {
|
||||
content,
|
||||
// todo!
|
||||
version: acp::FileVersion(0),
|
||||
})
|
||||
}
|
||||
|
||||
async fn glob_search(
|
||||
&self,
|
||||
_request: acp::GlobSearchParams,
|
||||
) -> Result<acp::GlobSearchResponse> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
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.update_thread(&request.thread_id.into(), cx, |thread, cx| {
|
||||
thread.request_tool_call(request.label, request.icon, request.confirmation, cx)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?;
|
||||
|
||||
Ok(acp::RequestToolCallConfirmationResponse {
|
||||
id: id.into(),
|
||||
outcome: outcome.await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn push_tool_call(
|
||||
&self,
|
||||
request: acp::PushToolCallParams,
|
||||
) -> Result<acp::PushToolCallResponse> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let entry_id = cx
|
||||
.update(|cx| {
|
||||
self.update_thread(&request.thread_id.into(), cx, |thread, cx| {
|
||||
thread.push_tool_call(request.label, request.icon, cx)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?;
|
||||
|
||||
Ok(acp::PushToolCallResponse {
|
||||
id: entry_id.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_tool_call(
|
||||
&self,
|
||||
request: acp::UpdateToolCallParams,
|
||||
) -> Result<acp::UpdateToolCallResponse> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
cx.update(|cx| {
|
||||
self.update_thread(&request.thread_id.into(), cx, |thread, cx| {
|
||||
thread.update_tool_call(
|
||||
request.tool_call_id.into(),
|
||||
request.status,
|
||||
request.content,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")??;
|
||||
|
||||
Ok(acp::UpdateToolCallResponse)
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpServer {
|
||||
pub fn stdio(mut process: Child, project: Entity<Project>, cx: &mut App) -> Arc<Self> {
|
||||
let stdin = process.stdin.take().expect("process didn't have stdin");
|
||||
let stdout = process.stdout.take().expect("process didn't have stdout");
|
||||
|
||||
let threads: Arc<Mutex<HashMap<ThreadId, WeakEntity<AcpThread>>>> = Default::default();
|
||||
let (connection, handler_fut, io_fut) = acp::AgentConnection::connect_to_agent(
|
||||
AcpClientDelegate::new(project.clone(), threads.clone(), cx.to_async()),
|
||||
stdin,
|
||||
stdout,
|
||||
);
|
||||
|
||||
let exit_status: Arc<Mutex<Option<ExitStatus>>> = Default::default();
|
||||
let io_task = cx.background_spawn({
|
||||
let exit_status = exit_status.clone();
|
||||
async move {
|
||||
io_fut.await.log_err();
|
||||
let result = process.status().await.log_err();
|
||||
*exit_status.lock() = result;
|
||||
}
|
||||
});
|
||||
|
||||
Arc::new(Self {
|
||||
project,
|
||||
connection: Arc::new(connection),
|
||||
threads,
|
||||
exit_status,
|
||||
_handler_task: cx.foreground_executor().spawn(handler_fut),
|
||||
_io_task: io_task,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&self) -> Result<acp::InitializeResponse> {
|
||||
self.connection
|
||||
.request(acp::InitializeParams)
|
||||
.await
|
||||
.map_err(to_anyhow)
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self) -> Result<()> {
|
||||
self.connection
|
||||
.request(acp::AuthenticateParams)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_thread(self: Arc<Self>, cx: &mut AsyncApp) -> Result<Entity<AcpThread>> {
|
||||
let response = self
|
||||
.connection
|
||||
.request(acp::CreateThreadParams)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
let thread_id: ThreadId = response.thread_id.into();
|
||||
let server = self.clone();
|
||||
let thread = cx.new(|_| AcpThread {
|
||||
// todo!
|
||||
title: "ACP Thread".into(),
|
||||
id: thread_id.clone(), // Either<ErrorState, Id>
|
||||
next_entry_id: ThreadEntryId(0),
|
||||
entries: Vec::default(),
|
||||
project: self.project.clone(),
|
||||
server,
|
||||
})?;
|
||||
self.threads.lock().insert(thread_id, thread.downgrade());
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
message: acp::Message,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
self.connection
|
||||
.request(acp::SendMessageParams {
|
||||
thread_id: thread_id.clone().into(),
|
||||
message,
|
||||
})
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exit_status(&self) -> Option<ExitStatus> {
|
||||
self.exit_status.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn to_anyhow(e: acp::Error) -> anyhow::Error {
|
||||
log::error!(
|
||||
"failed to send message: {code}: {message}",
|
||||
code = e.code,
|
||||
message = e.message
|
||||
);
|
||||
anyhow::anyhow!(e.message)
|
||||
}
|
||||
|
||||
impl From<acp::ThreadId> for ThreadId {
|
||||
fn from(thread_id: acp::ThreadId) -> Self {
|
||||
Self(thread_id.0.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThreadId> for acp::ThreadId {
|
||||
fn from(thread_id: ThreadId) -> Self {
|
||||
acp::ThreadId(thread_id.0.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<acp::ToolCallId> for ToolCallId {
|
||||
fn from(tool_call_id: acp::ToolCallId) -> Self {
|
||||
Self(ThreadEntryId(tool_call_id.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolCallId> for acp::ToolCallId {
|
||||
fn from(tool_call_id: ToolCallId) -> Self {
|
||||
acp::ToolCallId(tool_call_id.as_u64())
|
||||
}
|
||||
}
|
||||
1152
crates/acp/src/thread_view.rs
Normal file
1152
crates/acp/src/thread_view.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,10 @@ 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
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -109,6 +110,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,6 +132,7 @@ pub fn init(cx: &mut App) {
|
||||
let thread = thread.read(cx).thread().clone();
|
||||
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
|
||||
}
|
||||
ActiveView::AcpThread { .. } => todo!(),
|
||||
ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -188,6 +196,9 @@ enum ActiveView {
|
||||
message_editor: Entity<MessageEditor>,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
},
|
||||
AcpThread {
|
||||
thread_view: Entity<acp::AcpThreadView>,
|
||||
},
|
||||
TextThread {
|
||||
context_editor: Entity<TextThreadEditor>,
|
||||
title_editor: Entity<Editor>,
|
||||
@@ -207,7 +218,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 +251,9 @@ impl ActiveView {
|
||||
thread.scroll_to_bottom(cx);
|
||||
});
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
}
|
||||
ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -653,6 +669,9 @@ impl AgentPanel {
|
||||
.clone()
|
||||
.update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
}
|
||||
ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -733,6 +752,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());
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
|
||||
}
|
||||
}
|
||||
@@ -740,6 +762,10 @@ impl AgentPanel {
|
||||
fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> {
|
||||
match &self.active_view {
|
||||
ActiveView::Thread { message_editor, .. } => Some(message_editor),
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
None
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
|
||||
}
|
||||
}
|
||||
@@ -862,6 +888,19 @@ impl AgentPanel {
|
||||
context_editor.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let project = self.project.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let thread_view =
|
||||
cx.new_window_entity(|window, cx| acp::AcpThreadView::new(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 +1033,7 @@ impl AgentPanel {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let message_editor = cx.new(|cx| {
|
||||
MessageEditor::new(
|
||||
self.fs.clone(),
|
||||
@@ -1018,6 +1058,7 @@ impl AgentPanel {
|
||||
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match self.active_view {
|
||||
ActiveView::Configuration | ActiveView::History => {
|
||||
// todo! check go back works correctly
|
||||
if let Some(previous_view) = self.previous_view.take() {
|
||||
self.active_view = previous_view;
|
||||
|
||||
@@ -1025,6 +1066,9 @@ impl AgentPanel {
|
||||
ActiveView::Thread { message_editor, .. } => {
|
||||
message_editor.focus_handle(cx).focus(window);
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
todo!()
|
||||
}
|
||||
ActiveView::TextThread { context_editor, .. } => {
|
||||
context_editor.focus_handle(cx).focus(window);
|
||||
}
|
||||
@@ -1144,6 +1188,7 @@ impl AgentPanel {
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
ActiveView::AcpThread { .. } => todo!(),
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
|
||||
}
|
||||
}
|
||||
@@ -1197,6 +1242,9 @@ impl AgentPanel {
|
||||
)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
todo!()
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
|
||||
}
|
||||
}
|
||||
@@ -1231,6 +1279,10 @@ impl AgentPanel {
|
||||
pub(crate) fn active_thread(&self, cx: &App) -> Option<Entity<Thread>> {
|
||||
match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1336,6 +1388,9 @@ impl AgentPanel {
|
||||
});
|
||||
}
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1351,6 +1406,9 @@ impl AgentPanel {
|
||||
}
|
||||
})
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo! push history entry
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1437,6 +1495,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 +1652,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,6 +1789,10 @@ impl AgentPanel {
|
||||
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
None
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
|
||||
};
|
||||
|
||||
@@ -1755,6 +1821,7 @@ impl AgentPanel {
|
||||
menu = menu
|
||||
.action("New Thread", NewThread::default().boxed_clone())
|
||||
.action("New Text Thread", NewTextThread.boxed_clone())
|
||||
.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 +1960,10 @@ impl AgentPanel {
|
||||
message_editor,
|
||||
..
|
||||
} => (thread.read(cx), message_editor.read(cx)),
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
return None;
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
|
||||
return None;
|
||||
}
|
||||
@@ -2031,6 +2102,10 @@ impl AgentPanel {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
return false;
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
|
||||
return false;
|
||||
}
|
||||
@@ -2615,6 +2690,10 @@ impl AgentPanel {
|
||||
) -> Option<AnyElement> {
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => thread,
|
||||
ActiveView::AcpThread { .. } => {
|
||||
// todo!
|
||||
return None;
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
|
||||
return None;
|
||||
}
|
||||
@@ -2961,6 +3040,9 @@ impl AgentPanel {
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
unimplemented!()
|
||||
}
|
||||
ActiveView::TextThread { context_editor, .. } => {
|
||||
context_editor.update(cx, |context_editor, cx| {
|
||||
TextThreadEditor::insert_dragged_files(
|
||||
@@ -3034,6 +3116,9 @@ impl Render for AgentPanel {
|
||||
});
|
||||
this.continue_conversation(window, cx);
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
todo!()
|
||||
}
|
||||
ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -3075,6 +3160,12 @@ 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())
|
||||
// todo!
|
||||
// .child(h_flex().child(self.message_editor.clone()))
|
||||
.child(self.render_drag_target(cx)),
|
||||
ActiveView::History => parent.child(self.history.clone()),
|
||||
ActiveView::TextThread {
|
||||
context_editor,
|
||||
|
||||
@@ -55,6 +55,7 @@ actions!(
|
||||
agent,
|
||||
[
|
||||
NewTextThread,
|
||||
NewGeminiThread,
|
||||
ToggleContextPicker,
|
||||
ToggleNavigationMenu,
|
||||
ToggleOptionsMenu,
|
||||
@@ -65,7 +66,6 @@ actions!(
|
||||
OpenHistory,
|
||||
AddContextServer,
|
||||
RemoveSelectedThread,
|
||||
Chat,
|
||||
ChatWithFollow,
|
||||
CycleNextInlineAssist,
|
||||
CyclePreviousInlineAssist,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,12 @@ pub mod agent {
|
||||
|
||||
actions!(
|
||||
agent,
|
||||
[OpenConfiguration, OpenOnboardingModal, ResetOnboarding]
|
||||
[
|
||||
OpenConfiguration,
|
||||
OpenOnboardingModal,
|
||||
ResetOnboarding,
|
||||
Chat
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user