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