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