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,
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_4()
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) => match tool_call {
249 ToolCall::WaitingForConfirmation {
250 id,
251 tool_name,
252 description,
253 ..
254 } => {
255 let id = *id;
256 v_flex()
257 .elevation_1(cx)
258 .child(MarkdownElement::new(
259 tool_name.clone(),
260 default_markdown_style(window, cx),
261 ))
262 .child(MarkdownElement::new(
263 description.clone(),
264 default_markdown_style(window, cx),
265 ))
266 .child(
267 h_flex()
268 .child(Button::new(("allow", id.as_u64()), "Allow").on_click(
269 cx.listener({
270 move |this, _, _, cx| {
271 this.authorize_tool_call(id, true, cx);
272 }
273 }),
274 ))
275 .child(Button::new(("reject", id.as_u64()), "Reject").on_click(
276 cx.listener({
277 move |this, _, _, cx| {
278 this.authorize_tool_call(id, false, cx);
279 }
280 }),
281 )),
282 )
283 .into_any()
284 }
285 ToolCall::Allowed => div().child("Allowed!").into_any(),
286 ToolCall::Rejected => div().child("Rejected!").into_any(),
287 },
288 }
289 }
290}
291
292impl Focusable for AcpThreadView {
293 fn focus_handle(&self, cx: &App) -> FocusHandle {
294 self.message_editor.focus_handle(cx)
295 }
296}
297
298impl Render for AcpThreadView {
299 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
300 let text = self.message_editor.read(cx).text(cx);
301 let is_editor_empty = text.is_empty();
302 let focus_handle = self.message_editor.focus_handle(cx);
303
304 v_flex()
305 .key_context("MessageEditor")
306 .on_action(cx.listener(Self::chat))
307 .child(match &self.thread_state {
308 ThreadState::Loading { .. } => {
309 div().p_2().child(Label::new("Connecting to Gemini..."))
310 }
311 ThreadState::LoadError(e) => div()
312 .p_2()
313 .child(Label::new(format!("Failed to load {e}")).into_any_element()),
314 ThreadState::Ready { .. } => div().h_full().child(
315 list(self.list_state.clone())
316 .with_sizing_behavior(gpui::ListSizingBehavior::Infer),
317 ),
318 })
319 .when(self.send_task.is_some(), |this| {
320 this.child(
321 div().p_2().child(
322 Label::new("Generating...")
323 .color(Color::Muted)
324 .size(LabelSize::Small),
325 ),
326 )
327 })
328 .child(
329 div()
330 .bg(cx.theme().colors().editor_background)
331 .border_t_1()
332 .border_color(cx.theme().colors().border)
333 .p_2()
334 .child(self.message_editor.clone()),
335 )
336 .child(
337 h_flex()
338 .p_2()
339 .justify_end()
340 .child(if self.send_task.is_some() {
341 IconButton::new("stop-generation", IconName::StopFilled)
342 .icon_color(Color::Error)
343 .style(ButtonStyle::Tinted(ui::TintColor::Error))
344 .tooltip(move |window, cx| {
345 Tooltip::for_action(
346 "Stop Generation",
347 &editor::actions::Cancel,
348 window,
349 cx,
350 )
351 })
352 .disabled(is_editor_empty)
353 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
354 } else {
355 IconButton::new("send-message", IconName::Send)
356 .icon_color(Color::Accent)
357 .style(ButtonStyle::Filled)
358 .disabled(is_editor_empty)
359 .on_click({
360 let focus_handle = focus_handle.clone();
361 move |_event, window, cx| {
362 focus_handle.dispatch_action(&Chat, window, cx);
363 }
364 })
365 .when(!is_editor_empty, |button| {
366 button.tooltip(move |window, cx| {
367 Tooltip::for_action("Send", &Chat, window, cx)
368 })
369 })
370 .when(is_editor_empty, |button| {
371 button.tooltip(Tooltip::text("Type a message to submit"))
372 })
373 }),
374 )
375 }
376}
377
378fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
379 let mut style = default_markdown_style(window, cx);
380 let mut text_style = window.text_style();
381 let theme_settings = ThemeSettings::get_global(cx);
382
383 let buffer_font = theme_settings.buffer_font.family.clone();
384 let buffer_font_size = TextSize::Small.rems(cx);
385
386 text_style.refine(&TextStyleRefinement {
387 font_family: Some(buffer_font),
388 font_size: Some(buffer_font_size.into()),
389 ..Default::default()
390 });
391
392 style.base_text_style = text_style;
393 style
394}
395
396fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
397 let theme_settings = ThemeSettings::get_global(cx);
398 let colors = cx.theme().colors();
399 let ui_font_size = TextSize::Default.rems(cx);
400 let buffer_font_size = TextSize::Small.rems(cx);
401 let mut text_style = window.text_style();
402 let line_height = buffer_font_size * 1.75;
403
404 text_style.refine(&TextStyleRefinement {
405 font_family: Some(theme_settings.ui_font.family.clone()),
406 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
407 font_features: Some(theme_settings.ui_font.features.clone()),
408 font_size: Some(ui_font_size.into()),
409 line_height: Some(line_height.into()),
410 color: Some(cx.theme().colors().text),
411 ..Default::default()
412 });
413
414 MarkdownStyle {
415 base_text_style: text_style.clone(),
416 syntax: cx.theme().syntax().clone(),
417 selection_background_color: cx.theme().colors().element_selection_background,
418 code_block_overflow_x_scroll: true,
419 table_overflow_x_scroll: true,
420 heading_level_styles: Some(HeadingLevelStyles {
421 h1: Some(TextStyleRefinement {
422 font_size: Some(rems(1.15).into()),
423 ..Default::default()
424 }),
425 h2: Some(TextStyleRefinement {
426 font_size: Some(rems(1.1).into()),
427 ..Default::default()
428 }),
429 h3: Some(TextStyleRefinement {
430 font_size: Some(rems(1.05).into()),
431 ..Default::default()
432 }),
433 h4: Some(TextStyleRefinement {
434 font_size: Some(rems(1.).into()),
435 ..Default::default()
436 }),
437 h5: Some(TextStyleRefinement {
438 font_size: Some(rems(0.95).into()),
439 ..Default::default()
440 }),
441 h6: Some(TextStyleRefinement {
442 font_size: Some(rems(0.875).into()),
443 ..Default::default()
444 }),
445 }),
446 code_block: StyleRefinement {
447 padding: EdgesRefinement {
448 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
449 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
450 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
451 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
452 },
453 background: Some(colors.editor_background.into()),
454 text: Some(TextStyleRefinement {
455 font_family: Some(theme_settings.buffer_font.family.clone()),
456 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
457 font_features: Some(theme_settings.buffer_font.features.clone()),
458 font_size: Some(buffer_font_size.into()),
459 ..Default::default()
460 }),
461 ..Default::default()
462 },
463 inline_code: TextStyleRefinement {
464 font_family: Some(theme_settings.buffer_font.family.clone()),
465 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
466 font_features: Some(theme_settings.buffer_font.features.clone()),
467 font_size: Some(buffer_font_size.into()),
468 background_color: Some(colors.editor_foreground.opacity(0.08)),
469 ..Default::default()
470 },
471 link: TextStyleRefinement {
472 background_color: Some(colors.editor_foreground.opacity(0.025)),
473 underline: Some(UnderlineStyle {
474 color: Some(colors.text_accent.opacity(0.5)),
475 thickness: px(1.),
476 ..Default::default()
477 }),
478 ..Default::default()
479 },
480 link_callback: Some(Rc::new(move |_url, _cx| {
481 // todo!()
482 // if MentionLink::is_valid(url) {
483 // let colors = cx.theme().colors();
484 // Some(TextStyleRefinement {
485 // background_color: Some(colors.element_background),
486 // ..Default::default()
487 // })
488 // } else {
489 None
490 // }
491 })),
492 ..Default::default()
493 }
494}