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::Bottom,
128 px(2048.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_3()
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_5()
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 .h_full()
360 .child(match &self.thread_state {
361 ThreadState::Loading { .. } => v_flex()
362 .p_2()
363 .flex_1()
364 .justify_end()
365 .child(Label::new("Connecting to Gemini...")),
366 ThreadState::LoadError(e) => div()
367 .p_2()
368 .flex_1()
369 .justify_end()
370 .child(Label::new(format!("Failed to load {e}")).into_any_element()),
371 ThreadState::Ready { .. } => v_flex().flex_1().pb_4().child(
372 list(self.list_state.clone())
373 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
374 .flex_grow(),
375 ),
376 })
377 .when(self.send_task.is_some(), |this| {
378 this.child(
379 div().p_2().child(
380 Label::new("Generating...")
381 .color(Color::Muted)
382 .size(LabelSize::Small),
383 ),
384 )
385 })
386 .child(
387 div()
388 .bg(cx.theme().colors().editor_background)
389 .border_t_1()
390 .border_color(cx.theme().colors().border)
391 .p_2()
392 .child(self.message_editor.clone()),
393 )
394 .child(
395 h_flex()
396 .p_2()
397 .justify_end()
398 .child(if self.send_task.is_some() {
399 IconButton::new("stop-generation", IconName::StopFilled)
400 .icon_color(Color::Error)
401 .style(ButtonStyle::Tinted(ui::TintColor::Error))
402 .tooltip(move |window, cx| {
403 Tooltip::for_action(
404 "Stop Generation",
405 &editor::actions::Cancel,
406 window,
407 cx,
408 )
409 })
410 .disabled(is_editor_empty)
411 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
412 } else {
413 IconButton::new("send-message", IconName::Send)
414 .icon_color(Color::Accent)
415 .style(ButtonStyle::Filled)
416 .disabled(is_editor_empty)
417 .on_click({
418 let focus_handle = focus_handle.clone();
419 move |_event, window, cx| {
420 focus_handle.dispatch_action(&Chat, window, cx);
421 }
422 })
423 .when(!is_editor_empty, |button| {
424 button.tooltip(move |window, cx| {
425 Tooltip::for_action("Send", &Chat, window, cx)
426 })
427 })
428 .when(is_editor_empty, |button| {
429 button.tooltip(Tooltip::text("Type a message to submit"))
430 })
431 }),
432 )
433 }
434}
435
436fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
437 let mut style = default_markdown_style(window, cx);
438 let mut text_style = window.text_style();
439 let theme_settings = ThemeSettings::get_global(cx);
440
441 let buffer_font = theme_settings.buffer_font.family.clone();
442 let buffer_font_size = TextSize::Small.rems(cx);
443
444 text_style.refine(&TextStyleRefinement {
445 font_family: Some(buffer_font),
446 font_size: Some(buffer_font_size.into()),
447 ..Default::default()
448 });
449
450 style.base_text_style = text_style;
451 style
452}
453
454fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
455 let theme_settings = ThemeSettings::get_global(cx);
456 let colors = cx.theme().colors();
457 let ui_font_size = TextSize::Default.rems(cx);
458 let buffer_font_size = TextSize::Small.rems(cx);
459 let mut text_style = window.text_style();
460 let line_height = buffer_font_size * 1.75;
461
462 text_style.refine(&TextStyleRefinement {
463 font_family: Some(theme_settings.ui_font.family.clone()),
464 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
465 font_features: Some(theme_settings.ui_font.features.clone()),
466 font_size: Some(ui_font_size.into()),
467 line_height: Some(line_height.into()),
468 color: Some(cx.theme().colors().text),
469 ..Default::default()
470 });
471
472 MarkdownStyle {
473 base_text_style: text_style.clone(),
474 syntax: cx.theme().syntax().clone(),
475 selection_background_color: cx.theme().colors().element_selection_background,
476 code_block_overflow_x_scroll: true,
477 table_overflow_x_scroll: true,
478 heading_level_styles: Some(HeadingLevelStyles {
479 h1: Some(TextStyleRefinement {
480 font_size: Some(rems(1.15).into()),
481 ..Default::default()
482 }),
483 h2: Some(TextStyleRefinement {
484 font_size: Some(rems(1.1).into()),
485 ..Default::default()
486 }),
487 h3: Some(TextStyleRefinement {
488 font_size: Some(rems(1.05).into()),
489 ..Default::default()
490 }),
491 h4: Some(TextStyleRefinement {
492 font_size: Some(rems(1.).into()),
493 ..Default::default()
494 }),
495 h5: Some(TextStyleRefinement {
496 font_size: Some(rems(0.95).into()),
497 ..Default::default()
498 }),
499 h6: Some(TextStyleRefinement {
500 font_size: Some(rems(0.875).into()),
501 ..Default::default()
502 }),
503 }),
504 code_block: StyleRefinement {
505 padding: EdgesRefinement {
506 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
507 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
508 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
509 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
510 },
511 background: Some(colors.editor_background.into()),
512 text: Some(TextStyleRefinement {
513 font_family: Some(theme_settings.buffer_font.family.clone()),
514 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
515 font_features: Some(theme_settings.buffer_font.features.clone()),
516 font_size: Some(buffer_font_size.into()),
517 ..Default::default()
518 }),
519 ..Default::default()
520 },
521 inline_code: TextStyleRefinement {
522 font_family: Some(theme_settings.buffer_font.family.clone()),
523 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
524 font_features: Some(theme_settings.buffer_font.features.clone()),
525 font_size: Some(buffer_font_size.into()),
526 background_color: Some(colors.editor_foreground.opacity(0.08)),
527 ..Default::default()
528 },
529 link: TextStyleRefinement {
530 background_color: Some(colors.editor_foreground.opacity(0.025)),
531 underline: Some(UnderlineStyle {
532 color: Some(colors.text_accent.opacity(0.5)),
533 thickness: px(1.),
534 ..Default::default()
535 }),
536 ..Default::default()
537 },
538 link_callback: Some(Rc::new(move |_url, _cx| {
539 // todo!()
540 // if MentionLink::is_valid(url) {
541 // let colors = cx.theme().colors();
542 // Some(TextStyleRefinement {
543 // background_color: Some(colors.element_background),
544 // ..Default::default()
545 // })
546 // } else {
547 None
548 // }
549 })),
550 ..Default::default()
551 }
552}