Compare commits
59 commits
main
...
acp-markdo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
34c890e23e | ||
![]() |
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 |
15 changed files with 2232 additions and 19 deletions
47
Cargo.lock
generated
47
Cargo.lock
generated
|
@ -2,6 +2,36 @@
|
|||
# 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",
|
||||
"chrono",
|
||||
"collections",
|
||||
"editor",
|
||||
"env_logger 0.11.8",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"language",
|
||||
"log",
|
||||
"markdown",
|
||||
"parking_lot",
|
||||
"project",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"theme",
|
||||
"ui",
|
||||
"util",
|
||||
"uuid",
|
||||
"workspace-hack",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "activity_indicator"
|
||||
version = "0.1.0"
|
||||
|
@ -130,6 +160,7 @@ dependencies = [
|
|||
name = "agent_ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acp",
|
||||
"agent",
|
||||
"agent_settings",
|
||||
"anyhow",
|
||||
|
@ -212,6 +243,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 +14102,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"] }
|
||||
|
|
48
crates/acp/Cargo.toml
Normal file
48
crates/acp/Cargo.toml
Normal file
|
@ -0,0 +1,48 @@
|
|||
[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
|
||||
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
|
||||
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
|
677
crates/acp/src/acp.rs
Normal file
677
crates/acp/src/acp.rs
Normal file
|
@ -0,0 +1,677 @@
|
|||
mod server;
|
||||
mod thread_view;
|
||||
|
||||
use agentic_coding_protocol::{self as acp, Role};
|
||||
use anyhow::{Context as _, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
|
||||
use language::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>,
|
||||
},
|
||||
// todo! Running?
|
||||
Allowed {
|
||||
// todo! should this be variants in crate::ToolCallStatus instead?
|
||||
status: acp::ToolCallStatus,
|
||||
content: Option<Entity<Markdown>>,
|
||||
},
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
let acp::ToolCallContent::Markdown { markdown } = new_content;
|
||||
|
||||
cx.new(|cx| {
|
||||
Markdown::new(markdown.into(), Some(language_registry), None, cx)
|
||||
})
|
||||
});
|
||||
|
||||
*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, .. },
|
||||
..
|
||||
}) = &thread.entries()[1].content
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
content.as_ref().unwrap().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))
|
||||
}
|
||||
}
|
322
crates/acp/src/server.rs
Normal file
322
crates/acp/src/server.rs
Normal file
|
@ -0,0 +1,322 @@
|
|||
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, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
pub struct AcpServer {
|
||||
connection: Arc<acp::AgentConnection>,
|
||||
threads: Arc<Mutex<HashMap<ThreadId, WeakEntity<AcpThread>>>>,
|
||||
project: Entity<Project>,
|
||||
_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 AsyncApp) -> 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.clone()),
|
||||
stdin,
|
||||
stdout,
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn(async move {
|
||||
io_fut.await.log_err();
|
||||
process.status().await.log_err();
|
||||
});
|
||||
|
||||
Arc::new(Self {
|
||||
project,
|
||||
connection: Arc::new(connection),
|
||||
threads,
|
||||
_handler_task: cx.foreground_executor().spawn(handler_fut),
|
||||
_io_task: io_task,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpServer {
|
||||
pub async fn create_thread(self: Arc<Self>, cx: &mut AsyncApp) -> Result<Entity<AcpThread>> {
|
||||
let response = self.connection.request(acp::CreateThreadParams).await?;
|
||||
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(),
|
||||
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?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
935
crates/acp/src/thread_view.rs
Normal file
935
crates/acp/src/thread_view.rs
Normal file
|
@ -0,0 +1,935 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use agentic_coding_protocol::{self as acp, ToolCallConfirmation};
|
||||
use anyhow::Result;
|
||||
use editor::{Editor, MultiBuffer};
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, EdgesRefinement, Empty, Entity, Focusable, ListState,
|
||||
SharedString, StyleRefinement, Subscription, TextStyleRefinement, Transformation,
|
||||
UnderlineStyle, Window, div, list, percentage, prelude::*,
|
||||
};
|
||||
use gpui::{FocusHandle, Task};
|
||||
use language::Buffer;
|
||||
use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle};
|
||||
use project::Project;
|
||||
use settings::Settings as _;
|
||||
use theme::ThemeSettings;
|
||||
use ui::prelude::*;
|
||||
use ui::{Button, Tooltip};
|
||||
use util::ResultExt;
|
||||
use zed_actions::agent::Chat;
|
||||
|
||||
use crate::{
|
||||
AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry,
|
||||
ToolCall, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
|
||||
pub struct AcpThreadView {
|
||||
thread_state: ThreadState,
|
||||
// todo! use full message editor from agent2
|
||||
message_editor: Entity<Editor>,
|
||||
list_state: ListState,
|
||||
send_task: Option<Task<Result<()>>>,
|
||||
root: Arc<Path>,
|
||||
}
|
||||
|
||||
enum ThreadState {
|
||||
Loading {
|
||||
_task: Task<()>,
|
||||
},
|
||||
Ready {
|
||||
thread: Entity<AcpThread>,
|
||||
_subscription: Subscription,
|
||||
},
|
||||
LoadError(SharedString),
|
||||
}
|
||||
|
||||
impl AcpThreadView {
|
||||
pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// todo!(): This should probably be contextual, like the terminal
|
||||
let Some(root_dir) = project
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.map(|worktree| worktree.read(cx).abs_path())
|
||||
else {
|
||||
todo!();
|
||||
};
|
||||
|
||||
let cli_path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
|
||||
|
||||
let child = util::command::new_smol_command("node")
|
||||
.arg(cli_path)
|
||||
.arg("--acp")
|
||||
.current_dir(&root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let message_editor = cx.new(|cx| {
|
||||
let buffer = cx.new(|cx| Buffer::local("", 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("Send a message", cx);
|
||||
editor.set_soft_wrap();
|
||||
editor
|
||||
});
|
||||
|
||||
let project = project.clone();
|
||||
let load_task = cx.spawn_in(window, async move |this, cx| {
|
||||
let agent = AcpServer::stdio(child, project, cx);
|
||||
let result = agent.create_thread(cx).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match result {
|
||||
Ok(thread) => {
|
||||
let subscription = cx.subscribe(&thread, |this, _, event, cx| {
|
||||
let count = this.list_state.item_count();
|
||||
match event {
|
||||
AcpThreadEvent::NewEntry => {
|
||||
this.list_state.splice(count..count, 1);
|
||||
}
|
||||
AcpThreadEvent::EntryUpdated(index) => {
|
||||
this.list_state.splice(*index..*index + 1, 1);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
this.list_state
|
||||
.splice(0..0, thread.read(cx).entries().len());
|
||||
|
||||
this.thread_state = ThreadState::Ready {
|
||||
thread,
|
||||
_subscription: subscription,
|
||||
};
|
||||
}
|
||||
Err(e) => this.thread_state = ThreadState::LoadError(e.to_string().into()),
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
});
|
||||
|
||||
let list_state = ListState::new(
|
||||
0,
|
||||
gpui::ListAlignment::Bottom,
|
||||
px(2048.0),
|
||||
cx.processor({
|
||||
move |this: &mut Self, item: usize, window, cx| {
|
||||
let Some(entry) = this
|
||||
.thread()
|
||||
.and_then(|thread| thread.read(cx).entries.get(item))
|
||||
else {
|
||||
return Empty.into_any();
|
||||
};
|
||||
this.render_entry(entry, window, cx)
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
thread_state: ThreadState::Loading { _task: load_task },
|
||||
message_editor,
|
||||
send_task: None,
|
||||
list_state: list_state,
|
||||
root: root_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn thread(&self) -> Option<&Entity<AcpThread>> {
|
||||
match &self.thread_state {
|
||||
ThreadState::Ready { 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(&mut self) {
|
||||
self.send_task.take();
|
||||
}
|
||||
|
||||
fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let text = self.message_editor.read(cx).text(cx);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(thread) = self.thread() else { return };
|
||||
|
||||
let task = thread.update(cx, |thread, cx| thread.send(&text, cx));
|
||||
|
||||
self.send_task = Some(cx.spawn(async move |this, cx| {
|
||||
task.await?;
|
||||
|
||||
this.update(cx, |this, _cx| {
|
||||
this.send_task.take();
|
||||
})
|
||||
}));
|
||||
|
||||
self.message_editor.update(cx, |editor, cx| {
|
||||
editor.clear(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
entry: &ThreadEntry,
|
||||
window: &mut Window,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
match &entry.content {
|
||||
AgentThreadEntryContent::Message(message) => {
|
||||
let style = if message.role == Role::User {
|
||||
user_message_markdown_style(window, cx)
|
||||
} else {
|
||||
default_markdown_style(window, cx)
|
||||
};
|
||||
let message_body = div()
|
||||
.children(message.chunks.iter().map(|chunk| match chunk {
|
||||
MessageChunk::Text { chunk } => {
|
||||
// todo!() open link
|
||||
MarkdownElement::new(chunk.clone(), style.clone())
|
||||
}
|
||||
_ => todo!(),
|
||||
}))
|
||||
.into_any();
|
||||
|
||||
match message.role {
|
||||
Role::User => div()
|
||||
.p_2()
|
||||
.pt_5()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.p_3()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_lg()
|
||||
.shadow_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(message_body),
|
||||
)
|
||||
.into_any(),
|
||||
Role::Assistant => div()
|
||||
.text_ui(cx)
|
||||
.p_5()
|
||||
.pt_2()
|
||||
.child(message_body)
|
||||
.into_any(),
|
||||
}
|
||||
}
|
||||
AgentThreadEntryContent::ToolCall(tool_call) => div()
|
||||
.px_2()
|
||||
.py_4()
|
||||
.child(self.render_tool_call(tool_call, window, cx))
|
||||
.into_any(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tool_call(&self, tool_call: &ToolCall, window: &Window, cx: &Context<Self>) -> Div {
|
||||
let status_icon = match &tool_call.status {
|
||||
ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
|
||||
ToolCallStatus::Allowed {
|
||||
status: acp::ToolCallStatus::Running,
|
||||
..
|
||||
} => Icon::new(IconName::ArrowCircle)
|
||||
.color(Color::Success)
|
||||
.size(IconSize::Small)
|
||||
.with_animation(
|
||||
"running",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.into_any_element(),
|
||||
ToolCallStatus::Allowed {
|
||||
status: acp::ToolCallStatus::Finished,
|
||||
..
|
||||
} => Icon::new(IconName::Check)
|
||||
.color(Color::Success)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element(),
|
||||
ToolCallStatus::Rejected
|
||||
| ToolCallStatus::Allowed {
|
||||
status: acp::ToolCallStatus::Error,
|
||||
..
|
||||
} => Icon::new(IconName::X)
|
||||
.color(Color::Error)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
let content = match &tool_call.status {
|
||||
ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
|
||||
Some(self.render_tool_call_confirmation(tool_call.id, confirmation, cx))
|
||||
}
|
||||
ToolCallStatus::Allowed { content, .. } => content.clone().map(|content| {
|
||||
div()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_t_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.child(MarkdownElement::new(
|
||||
content,
|
||||
default_markdown_style(window, cx),
|
||||
))
|
||||
.into_any_element()
|
||||
}),
|
||||
ToolCallStatus::Rejected => None,
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.text_xs()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(tool_call.icon.into())
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
// todo! danilo please help
|
||||
.child(MarkdownElement::new(
|
||||
tool_call.label.clone(),
|
||||
default_markdown_style(window, cx),
|
||||
))
|
||||
.child(div().w_full())
|
||||
.child(status_icon),
|
||||
)
|
||||
.children(content)
|
||||
}
|
||||
|
||||
fn render_tool_call_confirmation(
|
||||
&self,
|
||||
tool_call_id: ToolCallId,
|
||||
confirmation: &ToolCallConfirmation,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
match confirmation {
|
||||
ToolCallConfirmation::Edit {
|
||||
file_name,
|
||||
file_diff,
|
||||
description,
|
||||
} => v_flex()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_t_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
// todo! nicer rendering
|
||||
.child(file_name.clone())
|
||||
.child(file_diff.clone())
|
||||
.children(description.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(
|
||||
("always_allow", tool_call_id.as_u64()),
|
||||
"Always Allow Edits",
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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,
|
||||
} => v_flex()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_t_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
// todo! nicer rendering
|
||||
.child(command.clone())
|
||||
.children(description.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(
|
||||
("always_allow", tool_call_id.as_u64()),
|
||||
format!("Always Allow {root_command}"),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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::Mcp {
|
||||
server_name,
|
||||
tool_name: _,
|
||||
tool_display_name,
|
||||
description,
|
||||
} => v_flex()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_t_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
// todo! nicer rendering
|
||||
.child(format!("{server_name} - {tool_display_name}"))
|
||||
.children(description.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(
|
||||
("always_allow_server", tool_call_id.as_u64()),
|
||||
format!("Always Allow {server_name}"),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Success)
|
||||
.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.as_u64()),
|
||||
format!("Always Allow {tool_display_name}"),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Success)
|
||||
.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.as_u64()), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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::Fetch { description, urls } => v_flex()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_t_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
// todo! nicer rendering
|
||||
.children(urls.clone())
|
||||
.children(description.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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::Other { description } => v_flex()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_t_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
// todo! nicer rendering
|
||||
.child(description.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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.as_u64()), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::Small)
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
v_flex()
|
||||
.key_context("MessageEditor")
|
||||
.on_action(cx.listener(Self::chat))
|
||||
.h_full()
|
||||
.child(match &self.thread_state {
|
||||
ThreadState::Loading { .. } => v_flex()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
.justify_end()
|
||||
.child(Label::new("Connecting to Gemini...")),
|
||||
ThreadState::LoadError(e) => div()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
.justify_end()
|
||||
.child(Label::new(format!("Failed to load {e}")).into_any_element()),
|
||||
ThreadState::Ready { thread, .. } => v_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.pb_2()
|
||||
.child(
|
||||
list(self.list_state.clone())
|
||||
.with_sizing_behavior(gpui::ListSizingBehavior::Auto)
|
||||
.flex_grow(),
|
||||
)
|
||||
.child(div().px_3().children(if self.send_task.is_none() {
|
||||
None
|
||||
} else {
|
||||
Label::new(if thread.read(cx).waiting_for_tool_confirmation() {
|
||||
"Waiting for tool confirmation"
|
||||
} else {
|
||||
"Generating..."
|
||||
})
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small)
|
||||
.into()
|
||||
})),
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.child(self.message_editor.clone())
|
||||
.child(h_flex().justify_end().child(if self.send_task.is_some() {
|
||||
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,
|
||||
)
|
||||
})
|
||||
.disabled(is_editor_empty)
|
||||
.on_click(cx.listener(|this, _event, _, _| this.cancel()))
|
||||
} else {
|
||||
IconButton::new("send-message", IconName::Send)
|
||||
.icon_color(Color::Accent)
|
||||
.style(ButtonStyle::Filled)
|
||||
.disabled(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"))
|
||||
})
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
|
||||
let mut style = default_markdown_style(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
|
||||
}
|
||||
|
||||
fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
|
||||
let theme_settings = ThemeSettings::get_global(cx);
|
||||
let colors = cx.theme().colors();
|
||||
let ui_font_size = TextSize::Default.rems(cx);
|
||||
let buffer_font_size = TextSize::Small.rems(cx);
|
||||
let mut text_style = window.text_style();
|
||||
let line_height = buffer_font_size * 1.75;
|
||||
|
||||
text_style.refine(&TextStyleRefinement {
|
||||
font_family: Some(theme_settings.ui_font.family.clone()),
|
||||
font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
|
||||
font_features: Some(theme_settings.ui_font.features.clone()),
|
||||
font_size: Some(ui_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.)))),
|
||||
},
|
||||
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()
|
||||
},
|
||||
link_callback: Some(Rc::new(move |_url, _cx| {
|
||||
// todo!()
|
||||
// if MentionLink::is_valid(url) {
|
||||
// let colors = cx.theme().colors();
|
||||
// Some(TextStyleRefinement {
|
||||
// background_color: Some(colors.element_background),
|
||||
// ..Default::default()
|
||||
// })
|
||||
// } else {
|
||||
None
|
||||
// }
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -199,6 +199,9 @@ impl Markdown {
|
|||
self.pending_parse.is_some()
|
||||
}
|
||||
|
||||
// Chunks: `Mark|down.md` You need to reparse every back tick, everytime
|
||||
// `[foo.rs](foo.rs)` [`foo.rs`](foo.rs) `ba|r.rs`
|
||||
|
||||
pub fn source(&self) -> &str {
|
||||
&self.source
|
||||
}
|
||||
|
@ -439,11 +442,25 @@ impl ParsedMarkdown {
|
|||
}
|
||||
}
|
||||
|
||||
// pub trait TextClickHandler {
|
||||
// fn pattern(&self) ->
|
||||
// fn hovered(&mut self, text: &str) -> bool;
|
||||
// fn clicked(&mut self, text: &str);
|
||||
// }
|
||||
// const WORD_REGEX: &str =
|
||||
// r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#;
|
||||
|
||||
pub struct UrlHandler {
|
||||
pub on_hover: Box<dyn Fn(&str, &mut Window, &mut App) -> bool>,
|
||||
pub on_click: Box<dyn Fn(&str, &mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
pub struct MarkdownElement {
|
||||
markdown: Entity<Markdown>,
|
||||
style: MarkdownStyle,
|
||||
code_block_renderer: CodeBlockRenderer,
|
||||
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
|
||||
on_link_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
|
||||
url_handler: Option<UrlHandler>,
|
||||
}
|
||||
|
||||
impl MarkdownElement {
|
||||
|
@ -456,7 +473,8 @@ impl MarkdownElement {
|
|||
copy_button_on_hover: false,
|
||||
border: false,
|
||||
},
|
||||
on_url_click: None,
|
||||
on_link_click: None,
|
||||
url_handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -490,7 +508,12 @@ impl MarkdownElement {
|
|||
mut self,
|
||||
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_url_click = Some(Box::new(handler));
|
||||
self.on_link_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn handle_urls(mut self, handler: UrlHandler) -> Self {
|
||||
self.url_handler = Some(handler);
|
||||
self
|
||||
}
|
||||
|
||||
|
@ -580,7 +603,7 @@ impl MarkdownElement {
|
|||
window.set_cursor_style(CursorStyle::IBeam, hitbox);
|
||||
}
|
||||
|
||||
let on_open_url = self.on_url_click.take();
|
||||
let on_open_url = self.on_link_click.take();
|
||||
|
||||
self.on_mouse_event(window, cx, {
|
||||
let rendered_text = rendered_text.clone();
|
||||
|
@ -591,6 +614,8 @@ impl MarkdownElement {
|
|||
if let Some(link) = rendered_text.link_for_position(event.position) {
|
||||
markdown.pressed_link = Some(link.clone());
|
||||
} else {
|
||||
// if
|
||||
|
||||
let source_index =
|
||||
match rendered_text.source_index_for_position(event.position) {
|
||||
Ok(ix) | Err(ix) => ix,
|
||||
|
@ -1798,8 +1823,10 @@ impl RenderedText {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::*;
|
||||
use gpui::{TestAppContext, size};
|
||||
use gpui::{Modifiers, MouseButton, TestAppContext, size};
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mappings(cx: &mut TestAppContext) {
|
||||
|
@ -1881,6 +1908,64 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_url_handling(cx: &mut TestAppContext) {
|
||||
let markdown = r#"hello `world`
|
||||
Check out `https://zed.dev` for a great editor!
|
||||
Also available locally: crates/ README.md,
|
||||
"#;
|
||||
|
||||
struct TestWindow;
|
||||
|
||||
impl Render for TestWindow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
|
||||
let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
let paths_hovered = Rc::new(RefCell::new(Vec::new()));
|
||||
let paths_clicked = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
let handler = {
|
||||
let paths_hovered = paths_hovered.clone();
|
||||
let paths_clicked = paths_clicked.clone();
|
||||
|
||||
UrlHandler {
|
||||
on_hover: Box::new(move |path, _window, _app| {
|
||||
paths_hovered.borrow_mut().push(path.to_string());
|
||||
true
|
||||
}),
|
||||
on_click: Box::new(move |path, _window, _app| {
|
||||
paths_clicked.borrow_mut().push(path.to_string());
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
let (rendered, _) = cx.draw(
|
||||
Default::default(),
|
||||
size(px(600.0), px(600.0)),
|
||||
|_window, _cx| {
|
||||
MarkdownElement::new(markdown, MarkdownStyle::default()).handle_urls(handler)
|
||||
},
|
||||
);
|
||||
|
||||
cx.simulate_mouse_move(
|
||||
point(px(0.0), px(0.0)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
|
||||
assert_eq!(paths_hovered.borrow().len(), 1)
|
||||
}
|
||||
|
||||
// To have a markdown document with paths and links in it
|
||||
// We want to run a function
|
||||
// and we want to get those paths and links out?
|
||||
|
||||
#[track_caller]
|
||||
fn assert_mappings(rendered: &RenderedText, expected: Vec<Vec<(usize, usize)>>) {
|
||||
assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");
|
||||
|
|
|
@ -65,7 +65,7 @@ pub async fn load_direnv_environment(
|
|||
let output = String::from_utf8_lossy(&direnv_output.stdout);
|
||||
if output.is_empty() {
|
||||
// direnv outputs nothing when it has no changes to apply to environment variables
|
||||
return Ok(HashMap::new());
|
||||
return Ok(HashMap::default());
|
||||
}
|
||||
|
||||
match serde_json::from_str(&output) {
|
||||
|
|
|
@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue