1mod server;
2mod thread_view;
3
4use agentic_coding_protocol::{self as acp, Role};
5use anyhow::Result;
6use chrono::{DateTime, Utc};
7use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
8use language::LanguageRegistry;
9use markdown::Markdown;
10use project::Project;
11use std::{ops::Range, path::PathBuf, sync::Arc};
12use ui::App;
13
14pub use server::AcpServer;
15pub use thread_view::AcpThreadView;
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct ThreadId(SharedString);
19
20#[derive(Copy, Clone, Debug, PartialEq, Eq)]
21pub struct FileVersion(u64);
22
23#[derive(Debug)]
24pub struct AgentThreadSummary {
25 pub id: ThreadId,
26 pub title: String,
27 pub created_at: DateTime<Utc>,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct FileContent {
32 pub path: PathBuf,
33 pub version: FileVersion,
34 pub content: SharedString,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct Message {
39 pub role: acp::Role,
40 pub chunks: Vec<MessageChunk>,
41}
42
43impl Message {
44 fn into_acp(self, cx: &App) -> acp::Message {
45 acp::Message {
46 role: self.role,
47 chunks: self
48 .chunks
49 .into_iter()
50 .map(|chunk| chunk.into_acp(cx))
51 .collect(),
52 }
53 }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum MessageChunk {
58 Text {
59 chunk: Entity<Markdown>,
60 },
61 File {
62 content: FileContent,
63 },
64 Directory {
65 path: PathBuf,
66 contents: Vec<FileContent>,
67 },
68 Symbol {
69 path: PathBuf,
70 range: Range<u64>,
71 version: FileVersion,
72 name: SharedString,
73 content: SharedString,
74 },
75 Fetch {
76 url: SharedString,
77 content: SharedString,
78 },
79}
80
81impl MessageChunk {
82 pub fn from_acp(
83 chunk: acp::MessageChunk,
84 language_registry: Arc<LanguageRegistry>,
85 cx: &mut App,
86 ) -> Self {
87 match chunk {
88 acp::MessageChunk::Text { chunk } => MessageChunk::Text {
89 chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
90 },
91 }
92 }
93
94 pub fn into_acp(self, cx: &App) -> acp::MessageChunk {
95 match self {
96 MessageChunk::Text { chunk } => acp::MessageChunk::Text {
97 chunk: chunk.read(cx).source().to_string(),
98 },
99 MessageChunk::File { .. } => todo!(),
100 MessageChunk::Directory { .. } => todo!(),
101 MessageChunk::Symbol { .. } => todo!(),
102 MessageChunk::Fetch { .. } => todo!(),
103 }
104 }
105
106 pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
107 MessageChunk::Text {
108 chunk: cx.new(|cx| {
109 Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
110 }),
111 }
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub enum AgentThreadEntryContent {
117 Message(Message),
118 ReadFile { path: PathBuf, content: String },
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
122pub struct ThreadEntryId(usize);
123
124impl ThreadEntryId {
125 pub fn post_inc(&mut self) -> Self {
126 let id = *self;
127 self.0 += 1;
128 id
129 }
130}
131
132#[derive(Debug)]
133pub struct ThreadEntry {
134 pub id: ThreadEntryId,
135 pub content: AgentThreadEntryContent,
136}
137
138pub struct AcpThread {
139 id: ThreadId,
140 next_entry_id: ThreadEntryId,
141 entries: Vec<ThreadEntry>,
142 server: Arc<AcpServer>,
143 title: SharedString,
144 project: Entity<Project>,
145}
146
147enum AcpThreadEvent {
148 NewEntry,
149 LastEntryUpdated,
150}
151
152impl EventEmitter<AcpThreadEvent> for AcpThread {}
153
154impl AcpThread {
155 pub fn new(
156 server: Arc<AcpServer>,
157 thread_id: ThreadId,
158 entries: Vec<AgentThreadEntryContent>,
159 project: Entity<Project>,
160 _: &mut Context<Self>,
161 ) -> Self {
162 let mut next_entry_id = ThreadEntryId(0);
163 Self {
164 title: "A new agent2 thread".into(),
165 entries: entries
166 .into_iter()
167 .map(|entry| ThreadEntry {
168 id: next_entry_id.post_inc(),
169 content: entry,
170 })
171 .collect(),
172 server,
173 id: thread_id,
174 next_entry_id,
175 project,
176 }
177 }
178
179 pub fn title(&self) -> SharedString {
180 self.title.clone()
181 }
182
183 pub fn entries(&self) -> &[ThreadEntry] {
184 &self.entries
185 }
186
187 pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context<Self>) {
188 self.entries.push(ThreadEntry {
189 id: self.next_entry_id.post_inc(),
190 content: entry,
191 });
192 cx.emit(AcpThreadEvent::NewEntry)
193 }
194
195 pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context<Self>) {
196 if let Some(last_entry) = self.entries.last_mut()
197 && let AgentThreadEntryContent::Message(Message {
198 ref mut chunks,
199 role: Role::Assistant,
200 }) = last_entry.content
201 {
202 cx.emit(AcpThreadEvent::LastEntryUpdated);
203
204 if let (
205 Some(MessageChunk::Text { chunk: old_chunk }),
206 acp::MessageChunk::Text { chunk: new_chunk },
207 ) = (chunks.last_mut(), &chunk)
208 {
209 old_chunk.update(cx, |old_chunk, cx| {
210 old_chunk.append(&new_chunk, cx);
211 });
212 } else {
213 chunks.push(MessageChunk::from_acp(
214 chunk,
215 self.project.read(cx).languages().clone(),
216 cx,
217 ));
218 }
219
220 return;
221 }
222
223 let chunk = MessageChunk::from_acp(chunk, self.project.read(cx).languages().clone(), cx);
224
225 self.push_entry(
226 AgentThreadEntryContent::Message(Message {
227 role: Role::Assistant,
228 chunks: vec![chunk],
229 }),
230 cx,
231 );
232 }
233
234 pub fn send(&mut self, message: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
235 let agent = self.server.clone();
236 let id = self.id.clone();
237 let chunk = MessageChunk::from_str(message, self.project.read(cx).languages().clone(), cx);
238 let message = Message {
239 role: Role::User,
240 chunks: vec![chunk],
241 };
242 self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx);
243 let acp_message = message.into_acp(cx);
244 cx.spawn(async move |_, cx| {
245 agent.send_message(id, acp_message, cx).await?;
246 Ok(())
247 })
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use gpui::{AsyncApp, TestAppContext};
255 use project::FakeFs;
256 use serde_json::json;
257 use settings::SettingsStore;
258 use std::{env, path::Path, process::Stdio};
259 use util::path;
260
261 fn init_test(cx: &mut TestAppContext) {
262 env_logger::init();
263 cx.update(|cx| {
264 let settings_store = SettingsStore::test(cx);
265 cx.set_global(settings_store);
266 Project::init_settings(cx);
267 language::init(cx);
268 });
269 }
270
271 #[gpui::test]
272 async fn test_gemini(cx: &mut TestAppContext) {
273 init_test(cx);
274
275 cx.executor().allow_parking();
276
277 let fs = FakeFs::new(cx.executor());
278 fs.insert_tree(
279 path!("/private/tmp"),
280 json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
281 )
282 .await;
283 let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
284 let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap();
285 let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
286 thread
287 .update(cx, |thread, cx| {
288 thread.send(
289 "Read the '/private/tmp/foo' file and output all of its contents.",
290 cx,
291 )
292 })
293 .await
294 .unwrap();
295
296 thread.read_with(cx, |thread, _| {
297 assert!(matches!(
298 thread.entries[0].content,
299 AgentThreadEntryContent::Message(Message {
300 role: Role::User,
301 ..
302 })
303 ));
304 assert!(
305 thread.entries().iter().any(|entry| {
306 entry.content
307 == AgentThreadEntryContent::ReadFile {
308 path: "/private/tmp/foo".into(),
309 content: "Lorem ipsum dolor".into(),
310 }
311 }),
312 "Thread does not contain entry. Actual: {:?}",
313 thread.entries()
314 );
315 });
316 }
317
318 pub fn gemini_acp_server(project: Entity<Project>, mut cx: AsyncApp) -> Result<Arc<AcpServer>> {
319 let cli_path =
320 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
321 let mut command = util::command::new_smol_command("node");
322 command
323 .arg(cli_path)
324 .arg("--acp")
325 .args(["--model", "gemini-2.5-flash"])
326 .current_dir("/private/tmp")
327 .stdin(Stdio::piped())
328 .stdout(Stdio::piped())
329 .stderr(Stdio::inherit())
330 .kill_on_drop(true);
331
332 if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") {
333 command.env("GEMINI_API_KEY", gemini_key);
334 }
335
336 let child = command.spawn().unwrap();
337
338 Ok(AcpServer::stdio(child, project, &mut cx))
339 }
340}