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