1use std::path::Path;
2use std::rc::Rc;
3use std::time::Duration;
4
5use agentic_coding_protocol::{self as acp};
6use anyhow::Result;
7use editor::{Editor, MultiBuffer};
8use gpui::{
9 Animation, AnimationExt, App, EdgesRefinement, Empty, Entity, Focusable, ListState,
10 SharedString, StyleRefinement, Subscription, TextStyleRefinement, Transformation,
11 UnderlineStyle, Window, div, list, percentage, prelude::*,
12};
13use gpui::{FocusHandle, Task};
14use language::Buffer;
15use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle};
16use project::Project;
17use settings::Settings as _;
18use theme::ThemeSettings;
19use ui::prelude::*;
20use ui::{Button, Tooltip};
21use util::ResultExt;
22use zed_actions::agent::Chat;
23
24use crate::{
25 AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry,
26 ToolCall, ToolCallId, ToolCallStatus,
27};
28
29pub struct AcpThreadView {
30 thread_state: ThreadState,
31 // todo! use full message editor from agent2
32 message_editor: Entity<Editor>,
33 list_state: ListState,
34 send_task: Option<Task<Result<()>>>,
35}
36
37enum ThreadState {
38 Loading {
39 _task: Task<()>,
40 },
41 Ready {
42 thread: Entity<AcpThread>,
43 _subscription: Subscription,
44 },
45 LoadError(SharedString),
46}
47
48impl AcpThreadView {
49 pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
50 let Some(root_dir) = project
51 .read(cx)
52 .visible_worktrees(cx)
53 .next()
54 .map(|worktree| worktree.read(cx).abs_path())
55 else {
56 todo!();
57 };
58
59 let cli_path =
60 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
61
62 let child = util::command::new_smol_command("node")
63 .arg(cli_path)
64 .arg("--acp")
65 .args(["--model", "gemini-2.5-flash"])
66 .current_dir(root_dir)
67 .stdin(std::process::Stdio::piped())
68 .stdout(std::process::Stdio::piped())
69 .stderr(std::process::Stdio::inherit())
70 .kill_on_drop(true)
71 .spawn()
72 .unwrap();
73
74 let message_editor = cx.new(|cx| {
75 let buffer = cx.new(|cx| Buffer::local("", cx));
76 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
77
78 let mut editor = Editor::new(
79 editor::EditorMode::AutoHeight {
80 min_lines: 4,
81 max_lines: None,
82 },
83 buffer,
84 None,
85 window,
86 cx,
87 );
88 editor.set_placeholder_text("Send a message", cx);
89 editor.set_soft_wrap();
90 editor
91 });
92
93 let project = project.clone();
94 let load_task = cx.spawn_in(window, async move |this, cx| {
95 let agent = AcpServer::stdio(child, project, cx);
96 let result = agent.create_thread(cx).await;
97
98 this.update(cx, |this, cx| {
99 match result {
100 Ok(thread) => {
101 let subscription = cx.subscribe(&thread, |this, _, event, cx| {
102 let count = this.list_state.item_count();
103 match event {
104 AcpThreadEvent::NewEntry => {
105 this.list_state.splice(count..count, 1);
106 }
107 AcpThreadEvent::EntryUpdated(index) => {
108 this.list_state.splice(*index..*index + 1, 1);
109 }
110 }
111 cx.notify();
112 });
113 this.list_state
114 .splice(0..0, thread.read(cx).entries().len());
115
116 this.thread_state = ThreadState::Ready {
117 thread,
118 _subscription: subscription,
119 };
120 }
121 Err(e) => this.thread_state = ThreadState::LoadError(e.to_string().into()),
122 };
123 cx.notify();
124 })
125 .log_err();
126 });
127
128 let list_state = ListState::new(
129 0,
130 gpui::ListAlignment::Bottom,
131 px(2048.0),
132 cx.processor({
133 move |this: &mut Self, item: usize, window, cx| {
134 let Some(entry) = this
135 .thread()
136 .and_then(|thread| thread.read(cx).entries.get(item))
137 else {
138 return Empty.into_any();
139 };
140 this.render_entry(entry, window, cx)
141 }
142 }),
143 );
144
145 Self {
146 thread_state: ThreadState::Loading { _task: load_task },
147 message_editor,
148 send_task: None,
149 list_state: list_state,
150 }
151 }
152
153 fn thread(&self) -> Option<&Entity<AcpThread>> {
154 match &self.thread_state {
155 ThreadState::Ready { thread, .. } => Some(thread),
156 ThreadState::Loading { .. } | ThreadState::LoadError(..) => None,
157 }
158 }
159
160 pub fn title(&self, cx: &App) -> SharedString {
161 match &self.thread_state {
162 ThreadState::Ready { thread, .. } => thread.read(cx).title(),
163 ThreadState::Loading { .. } => "Loading...".into(),
164 ThreadState::LoadError(_) => "Failed to load".into(),
165 }
166 }
167
168 pub fn cancel(&mut self) {
169 self.send_task.take();
170 }
171
172 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
173 let text = self.message_editor.read(cx).text(cx);
174 if text.is_empty() {
175 return;
176 }
177 let Some(thread) = self.thread() else { return };
178
179 let task = thread.update(cx, |thread, cx| thread.send(&text, cx));
180
181 self.send_task = Some(cx.spawn(async move |this, cx| {
182 task.await?;
183
184 this.update(cx, |this, _cx| {
185 this.send_task.take();
186 })
187 }));
188
189 self.message_editor.update(cx, |editor, cx| {
190 editor.clear(window, cx);
191 });
192 }
193
194 fn authorize_tool_call(&mut self, id: ToolCallId, allowed: bool, cx: &mut Context<Self>) {
195 let Some(thread) = self.thread() else {
196 return;
197 };
198 thread.update(cx, |thread, cx| {
199 thread.authorize_tool_call(id, allowed, cx);
200 });
201 cx.notify();
202 }
203
204 fn render_entry(
205 &self,
206 entry: &ThreadEntry,
207 window: &mut Window,
208 cx: &Context<Self>,
209 ) -> AnyElement {
210 match &entry.content {
211 AgentThreadEntryContent::Message(message) => {
212 let style = if message.role == Role::User {
213 user_message_markdown_style(window, cx)
214 } else {
215 default_markdown_style(window, cx)
216 };
217 let message_body = div()
218 .children(message.chunks.iter().map(|chunk| match chunk {
219 MessageChunk::Text { chunk } => {
220 // todo!() open link
221 MarkdownElement::new(chunk.clone(), style.clone())
222 }
223 _ => todo!(),
224 }))
225 .into_any();
226
227 match message.role {
228 Role::User => div()
229 .p_2()
230 .pt_5()
231 .child(
232 div()
233 .text_xs()
234 .p_3()
235 .bg(cx.theme().colors().editor_background)
236 .rounded_lg()
237 .shadow_md()
238 .border_1()
239 .border_color(cx.theme().colors().border)
240 .child(message_body),
241 )
242 .into_any(),
243 Role::Assistant => div()
244 .text_ui(cx)
245 .p_5()
246 .pt_2()
247 .child(message_body)
248 .into_any(),
249 }
250 }
251 AgentThreadEntryContent::ToolCall(tool_call) => div()
252 .px_2()
253 .py_4()
254 .child(self.render_tool_call(tool_call, window, cx))
255 .into_any(),
256 }
257 }
258
259 fn render_tool_call(&self, tool_call: &ToolCall, window: &Window, cx: &Context<Self>) -> Div {
260 let status_icon = match &tool_call.status {
261 ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
262 ToolCallStatus::Allowed {
263 status: acp::ToolCallStatus::Running,
264 ..
265 } => Icon::new(IconName::ArrowCircle)
266 .color(Color::Success)
267 .size(IconSize::Small)
268 .with_animation(
269 "running",
270 Animation::new(Duration::from_secs(2)).repeat(),
271 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
272 )
273 .into_any_element(),
274 ToolCallStatus::Allowed {
275 status: acp::ToolCallStatus::Finished,
276 ..
277 } => Icon::new(IconName::Check)
278 .color(Color::Success)
279 .size(IconSize::Small)
280 .into_any_element(),
281 ToolCallStatus::Rejected
282 | ToolCallStatus::Allowed {
283 status: acp::ToolCallStatus::Error,
284 ..
285 } => Icon::new(IconName::X)
286 .color(Color::Error)
287 .size(IconSize::Small)
288 .into_any_element(),
289 };
290
291 let content = match &tool_call.status {
292 ToolCallStatus::WaitingForConfirmation { description, .. } => v_flex()
293 .border_color(cx.theme().colors().border)
294 .border_t_1()
295 .px_2()
296 .py_1p5()
297 .child(MarkdownElement::new(
298 description.clone(),
299 default_markdown_style(window, cx),
300 ))
301 .child(
302 h_flex()
303 .justify_end()
304 .gap_1()
305 .child(
306 Button::new(("allow", tool_call.id.as_u64()), "Allow")
307 .icon(IconName::Check)
308 .icon_position(IconPosition::Start)
309 .icon_size(IconSize::Small)
310 .icon_color(Color::Success)
311 .on_click(cx.listener({
312 let id = tool_call.id;
313 move |this, _, _, cx| {
314 this.authorize_tool_call(id, true, cx);
315 }
316 })),
317 )
318 .child(
319 Button::new(("reject", tool_call.id.as_u64()), "Reject")
320 .icon(IconName::X)
321 .icon_position(IconPosition::Start)
322 .icon_size(IconSize::Small)
323 .icon_color(Color::Error)
324 .on_click(cx.listener({
325 let id = tool_call.id;
326 move |this, _, _, cx| {
327 this.authorize_tool_call(id, false, cx);
328 }
329 })),
330 ),
331 )
332 .into_any()
333 .into(),
334 ToolCallStatus::Allowed { content, .. } => content.clone().map(|content| {
335 div()
336 .border_color(cx.theme().colors().border)
337 .border_t_1()
338 .px_2()
339 .py_1p5()
340 .child(MarkdownElement::new(
341 content,
342 default_markdown_style(window, cx),
343 ))
344 .into_any_element()
345 }),
346 ToolCallStatus::Rejected => None,
347 };
348
349 v_flex()
350 .text_xs()
351 .rounded_md()
352 .border_1()
353 .border_color(cx.theme().colors().border)
354 .bg(cx.theme().colors().editor_background)
355 .child(
356 h_flex()
357 .px_2()
358 .py_1p5()
359 .w_full()
360 .gap_1p5()
361 .child(
362 Icon::new(IconName::Cog)
363 .size(IconSize::Small)
364 .color(Color::Muted),
365 )
366 .child(MarkdownElement::new(
367 tool_call.tool_name.clone(),
368 default_markdown_style(window, cx),
369 ))
370 .child(div().w_full())
371 .child(status_icon),
372 )
373 .children(content)
374 }
375}
376
377impl Focusable for AcpThreadView {
378 fn focus_handle(&self, cx: &App) -> FocusHandle {
379 self.message_editor.focus_handle(cx)
380 }
381}
382
383impl Render for AcpThreadView {
384 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
385 let text = self.message_editor.read(cx).text(cx);
386 let is_editor_empty = text.is_empty();
387 let focus_handle = self.message_editor.focus_handle(cx);
388
389 v_flex()
390 .key_context("MessageEditor")
391 .on_action(cx.listener(Self::chat))
392 .h_full()
393 .child(match &self.thread_state {
394 ThreadState::Loading { .. } => v_flex()
395 .p_2()
396 .flex_1()
397 .justify_end()
398 .child(Label::new("Connecting to Gemini...")),
399 ThreadState::LoadError(e) => div()
400 .p_2()
401 .flex_1()
402 .justify_end()
403 .child(Label::new(format!("Failed to load {e}")).into_any_element()),
404 ThreadState::Ready { thread, .. } => v_flex()
405 .flex_1()
406 .gap_2()
407 .pb_2()
408 .child(
409 list(self.list_state.clone())
410 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
411 .flex_grow(),
412 )
413 .child(div().px_3().children(if self.send_task.is_none() {
414 None
415 } else {
416 Label::new(if thread.read(cx).waiting_for_tool_confirmation() {
417 "Waiting for tool confirmation"
418 } else {
419 "Generating..."
420 })
421 .color(Color::Muted)
422 .size(LabelSize::Small)
423 .into()
424 })),
425 })
426 .child(
427 v_flex()
428 .bg(cx.theme().colors().editor_background)
429 .border_t_1()
430 .border_color(cx.theme().colors().border)
431 .p_2()
432 .gap_2()
433 .child(self.message_editor.clone())
434 .child(h_flex().justify_end().child(if self.send_task.is_some() {
435 IconButton::new("stop-generation", IconName::StopFilled)
436 .icon_color(Color::Error)
437 .style(ButtonStyle::Tinted(ui::TintColor::Error))
438 .tooltip(move |window, cx| {
439 Tooltip::for_action(
440 "Stop Generation",
441 &editor::actions::Cancel,
442 window,
443 cx,
444 )
445 })
446 .disabled(is_editor_empty)
447 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
448 } else {
449 IconButton::new("send-message", IconName::Send)
450 .icon_color(Color::Accent)
451 .style(ButtonStyle::Filled)
452 .disabled(is_editor_empty)
453 .on_click({
454 let focus_handle = focus_handle.clone();
455 move |_event, window, cx| {
456 focus_handle.dispatch_action(&Chat, window, cx);
457 }
458 })
459 .when(!is_editor_empty, |button| {
460 button.tooltip(move |window, cx| {
461 Tooltip::for_action("Send", &Chat, window, cx)
462 })
463 })
464 .when(is_editor_empty, |button| {
465 button.tooltip(Tooltip::text("Type a message to submit"))
466 })
467 })),
468 )
469 }
470}
471
472fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
473 let mut style = default_markdown_style(window, cx);
474 let mut text_style = window.text_style();
475 let theme_settings = ThemeSettings::get_global(cx);
476
477 let buffer_font = theme_settings.buffer_font.family.clone();
478 let buffer_font_size = TextSize::Small.rems(cx);
479
480 text_style.refine(&TextStyleRefinement {
481 font_family: Some(buffer_font),
482 font_size: Some(buffer_font_size.into()),
483 ..Default::default()
484 });
485
486 style.base_text_style = text_style;
487 style
488}
489
490fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
491 let theme_settings = ThemeSettings::get_global(cx);
492 let colors = cx.theme().colors();
493 let ui_font_size = TextSize::Default.rems(cx);
494 let buffer_font_size = TextSize::Small.rems(cx);
495 let mut text_style = window.text_style();
496 let line_height = buffer_font_size * 1.75;
497
498 text_style.refine(&TextStyleRefinement {
499 font_family: Some(theme_settings.ui_font.family.clone()),
500 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
501 font_features: Some(theme_settings.ui_font.features.clone()),
502 font_size: Some(ui_font_size.into()),
503 line_height: Some(line_height.into()),
504 color: Some(cx.theme().colors().text),
505 ..Default::default()
506 });
507
508 MarkdownStyle {
509 base_text_style: text_style.clone(),
510 syntax: cx.theme().syntax().clone(),
511 selection_background_color: cx.theme().colors().element_selection_background,
512 code_block_overflow_x_scroll: true,
513 table_overflow_x_scroll: true,
514 heading_level_styles: Some(HeadingLevelStyles {
515 h1: Some(TextStyleRefinement {
516 font_size: Some(rems(1.15).into()),
517 ..Default::default()
518 }),
519 h2: Some(TextStyleRefinement {
520 font_size: Some(rems(1.1).into()),
521 ..Default::default()
522 }),
523 h3: Some(TextStyleRefinement {
524 font_size: Some(rems(1.05).into()),
525 ..Default::default()
526 }),
527 h4: Some(TextStyleRefinement {
528 font_size: Some(rems(1.).into()),
529 ..Default::default()
530 }),
531 h5: Some(TextStyleRefinement {
532 font_size: Some(rems(0.95).into()),
533 ..Default::default()
534 }),
535 h6: Some(TextStyleRefinement {
536 font_size: Some(rems(0.875).into()),
537 ..Default::default()
538 }),
539 }),
540 code_block: StyleRefinement {
541 padding: EdgesRefinement {
542 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
543 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
544 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
545 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
546 },
547 background: Some(colors.editor_background.into()),
548 text: Some(TextStyleRefinement {
549 font_family: Some(theme_settings.buffer_font.family.clone()),
550 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
551 font_features: Some(theme_settings.buffer_font.features.clone()),
552 font_size: Some(buffer_font_size.into()),
553 ..Default::default()
554 }),
555 ..Default::default()
556 },
557 inline_code: TextStyleRefinement {
558 font_family: Some(theme_settings.buffer_font.family.clone()),
559 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
560 font_features: Some(theme_settings.buffer_font.features.clone()),
561 font_size: Some(buffer_font_size.into()),
562 background_color: Some(colors.editor_foreground.opacity(0.08)),
563 ..Default::default()
564 },
565 link: TextStyleRefinement {
566 background_color: Some(colors.editor_foreground.opacity(0.025)),
567 underline: Some(UnderlineStyle {
568 color: Some(colors.text_accent.opacity(0.5)),
569 thickness: px(1.),
570 ..Default::default()
571 }),
572 ..Default::default()
573 },
574 link_callback: Some(Rc::new(move |_url, _cx| {
575 // todo!()
576 // if MentionLink::is_valid(url) {
577 // let colors = cx.theme().colors();
578 // Some(TextStyleRefinement {
579 // background_color: Some(colors.element_background),
580 // ..Default::default()
581 // })
582 // } else {
583 None
584 // }
585 })),
586 ..Default::default()
587 }
588}