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