1use std::path::Path;
2use std::rc::Rc;
3use std::sync::Arc;
4use std::time::Duration;
5
6use agentic_coding_protocol::{self as acp};
7use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer};
8use gpui::{
9 Animation, AnimationExt, App, EdgesRefinement, Empty, Entity, Focusable, ListState,
10 SharedString, StyleRefinement, Subscription, TextStyleRefinement, Transformation,
11 UnderlineStyle, Window, div, list, percentage, prelude::*,
12};
13use gpui::{FocusHandle, Task};
14use language::Buffer;
15use language::language_settings::SoftWrap;
16use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
17use project::Project;
18use settings::Settings as _;
19use theme::ThemeSettings;
20use ui::prelude::*;
21use ui::{Button, Tooltip};
22use util::{ResultExt, paths};
23use zed_actions::agent::Chat;
24
25use crate::{
26 AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, Diff, MessageChunk, Role,
27 ThreadEntry, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent, ToolCallId,
28 ToolCallStatus,
29};
30
31pub struct AcpThreadView {
32 agent: Arc<AcpServer>,
33 thread_state: ThreadState,
34 // todo! reconsider structure. currently pretty sparse, but easy to clean up if we need to delete entries.
35 thread_entry_views: Vec<Option<ThreadEntryView>>,
36 message_editor: Entity<Editor>,
37 last_error: Option<Entity<Markdown>>,
38 list_state: ListState,
39 auth_task: Option<Task<()>>,
40}
41
42#[derive(Debug)]
43enum ThreadEntryView {
44 Diff { editor: Entity<Editor> },
45}
46
47enum ThreadState {
48 Loading {
49 _task: Task<()>,
50 },
51 Ready {
52 thread: Entity<AcpThread>,
53 _subscription: Subscription,
54 },
55 LoadError(SharedString),
56 Unauthenticated,
57}
58
59impl AcpThreadView {
60 pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
61 let message_editor = cx.new(|cx| {
62 let buffer = cx.new(|cx| Buffer::local("", cx));
63 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
64
65 let mut editor = Editor::new(
66 editor::EditorMode::AutoHeight {
67 min_lines: 4,
68 max_lines: None,
69 },
70 buffer,
71 None,
72 window,
73 cx,
74 );
75 editor.set_placeholder_text("Send a message", cx);
76 editor.set_soft_wrap();
77 editor
78 });
79
80 let list_state = ListState::new(
81 0,
82 gpui::ListAlignment::Bottom,
83 px(2048.0),
84 cx.processor({
85 move |this: &mut Self, item: usize, window, cx| {
86 let Some(entry) = this
87 .thread()
88 .and_then(|thread| thread.read(cx).entries.get(item))
89 else {
90 return Empty.into_any();
91 };
92 this.render_entry(item, entry, window, cx)
93 }
94 }),
95 );
96
97 let root_dir = project
98 .read(cx)
99 .visible_worktrees(cx)
100 .next()
101 .map(|worktree| worktree.read(cx).abs_path())
102 .unwrap_or_else(|| paths::home_dir().as_path().into());
103
104 let cli_path =
105 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
106
107 let child = util::command::new_smol_command("node")
108 .arg(cli_path)
109 .arg("--acp")
110 .current_dir(root_dir)
111 .stdin(std::process::Stdio::piped())
112 .stdout(std::process::Stdio::piped())
113 .stderr(std::process::Stdio::inherit())
114 .kill_on_drop(true)
115 .spawn()
116 .unwrap();
117
118 let agent = AcpServer::stdio(child, project, cx);
119
120 Self {
121 thread_state: Self::initial_state(agent.clone(), window, cx),
122 agent,
123 message_editor,
124 thread_entry_views: Vec::new(),
125 list_state: list_state,
126 last_error: None,
127 auth_task: None,
128 }
129 }
130
131 fn initial_state(
132 agent: Arc<AcpServer>,
133 window: &mut Window,
134 cx: &mut Context<Self>,
135 ) -> ThreadState {
136 let load_task = cx.spawn_in(window, async move |this, cx| {
137 let result = match agent.initialize().await {
138 Err(e) => Err(e),
139 Ok(response) => {
140 if !response.is_authenticated {
141 this.update(cx, |this, _| {
142 this.thread_state = ThreadState::Unauthenticated;
143 })
144 .ok();
145 return;
146 }
147 agent.clone().create_thread(cx).await
148 }
149 };
150
151 this.update_in(cx, |this, window, cx| {
152 match result {
153 Ok(thread) => {
154 let subscription =
155 cx.subscribe_in(&thread, window, Self::handle_thread_event);
156 this.list_state
157 .splice(0..0, thread.read(cx).entries().len());
158
159 this.thread_state = ThreadState::Ready {
160 thread,
161 _subscription: subscription,
162 };
163 }
164 Err(e) => {
165 if let Some(exit_status) = agent.exit_status() {
166 this.thread_state = ThreadState::LoadError(
167 format!(
168 "Gemini exited with status {}",
169 exit_status.code().unwrap_or(-127)
170 )
171 .into(),
172 )
173 } else {
174 this.thread_state = ThreadState::LoadError(e.to_string().into())
175 }
176 }
177 };
178 cx.notify();
179 })
180 .log_err();
181 });
182
183 ThreadState::Loading { _task: load_task }
184 }
185
186 fn thread(&self) -> Option<&Entity<AcpThread>> {
187 match &self.thread_state {
188 ThreadState::Ready { thread, .. } => Some(thread),
189 ThreadState::Loading { .. }
190 | ThreadState::LoadError(..)
191 | ThreadState::Unauthenticated => None,
192 }
193 }
194
195 pub fn title(&self, cx: &App) -> SharedString {
196 match &self.thread_state {
197 ThreadState::Ready { thread, .. } => thread.read(cx).title(),
198 ThreadState::Loading { .. } => "Loading...".into(),
199 ThreadState::LoadError(_) => "Failed to load".into(),
200 ThreadState::Unauthenticated => "Not authenticated".into(),
201 }
202 }
203
204 pub fn cancel(&mut self, cx: &mut Context<Self>) {
205 self.last_error.take();
206
207 if let Some(thread) = self.thread() {
208 thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
209 }
210 }
211
212 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
213 self.last_error.take();
214 let text = self.message_editor.read(cx).text(cx);
215 if text.is_empty() {
216 return;
217 }
218 let Some(thread) = self.thread() else { return };
219
220 let task = thread.update(cx, |thread, cx| thread.send(&text, cx));
221
222 cx.spawn(async move |this, cx| {
223 let result = task.await;
224
225 this.update(cx, |this, cx| {
226 if let Err(err) = result {
227 this.last_error =
228 Some(cx.new(|cx| {
229 Markdown::new(format!("Error: {err}").into(), None, None, cx)
230 }))
231 }
232 })
233 })
234 .detach();
235
236 self.message_editor.update(cx, |editor, cx| {
237 editor.clear(window, cx);
238 });
239 }
240
241 fn handle_thread_event(
242 &mut self,
243 thread: &Entity<AcpThread>,
244 event: &AcpThreadEvent,
245 window: &mut Window,
246 cx: &mut Context<Self>,
247 ) {
248 let count = self.list_state.item_count();
249 match event {
250 AcpThreadEvent::NewEntry => {
251 self.sync_thread_entry_view(thread.read(cx).entries.len() - 1, window, cx);
252 self.list_state.splice(count..count, 1);
253 }
254 AcpThreadEvent::EntryUpdated(index) => {
255 let index = *index;
256 self.sync_thread_entry_view(index, window, cx);
257 self.list_state.splice(index..index + 1, 1);
258 }
259 }
260 cx.notify();
261 }
262
263 // todo! should we do this on the fly from render?
264 fn sync_thread_entry_view(
265 &mut self,
266 entry_ix: usize,
267 window: &mut Window,
268 cx: &mut Context<Self>,
269 ) {
270 let multibuffer = match (
271 self.entry_diff_multibuffer(entry_ix, cx),
272 self.thread_entry_views.get(entry_ix),
273 ) {
274 (Some(multibuffer), Some(Some(ThreadEntryView::Diff { editor }))) => {
275 if editor.read(cx).buffer() == &multibuffer {
276 // same buffer, all synced up
277 return;
278 }
279 // new buffer, replace editor
280 multibuffer
281 }
282 (Some(multibuffer), _) => multibuffer,
283 (None, Some(Some(ThreadEntryView::Diff { .. }))) => {
284 // no longer displaying a diff, drop editor
285 self.thread_entry_views[entry_ix] = None;
286 return;
287 }
288 (None, _) => return,
289 };
290
291 let editor = cx.new(|cx| {
292 let mut editor = Editor::new(
293 EditorMode::Full {
294 scale_ui_elements_with_buffer_font_size: false,
295 show_active_line_background: false,
296 sized_by_content: true,
297 },
298 multibuffer.clone(),
299 None,
300 window,
301 cx,
302 );
303 editor.set_show_gutter(false, cx);
304 editor.disable_inline_diagnostics();
305 editor.disable_expand_excerpt_buttons(cx);
306 editor.set_show_vertical_scrollbar(false, cx);
307 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
308 editor.set_soft_wrap_mode(SoftWrap::None, cx);
309 editor.scroll_manager.set_forbid_vertical_scroll(true);
310 editor.set_show_indent_guides(false, cx);
311 editor.set_read_only(true);
312 editor.set_show_breakpoints(false, cx);
313 editor.set_show_code_actions(false, cx);
314 editor.set_show_git_diff_gutter(false, cx);
315 editor.set_expand_all_diff_hunks(cx);
316 editor.set_text_style_refinement(TextStyleRefinement {
317 font_size: Some(
318 TextSize::Small
319 .rems(cx)
320 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
321 .into(),
322 ),
323 ..Default::default()
324 });
325 editor
326 });
327
328 if entry_ix >= self.thread_entry_views.len() {
329 self.thread_entry_views
330 .resize_with(entry_ix + 1, Default::default);
331 }
332
333 self.thread_entry_views[entry_ix] = Some(ThreadEntryView::Diff {
334 editor: editor.clone(),
335 });
336 }
337
338 fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
339 let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
340 if let AgentThreadEntryContent::ToolCall(ToolCall {
341 content: Some(ToolCallContent::Diff { diff }),
342 ..
343 }) = &entry.content
344 {
345 Some(diff.multibuffer.clone())
346 } else {
347 None
348 }
349 }
350
351 fn authenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
352 let agent = self.agent.clone();
353
354 self.auth_task = Some(cx.spawn_in(window, async move |this, cx| {
355 let result = agent.authenticate().await;
356
357 this.update_in(cx, |this, window, cx| {
358 if let Err(err) = result {
359 this.last_error =
360 Some(cx.new(|cx| {
361 Markdown::new(format!("Error: {err}").into(), None, None, cx)
362 }))
363 } else {
364 this.thread_state = Self::initial_state(agent, window, cx)
365 }
366 this.auth_task.take()
367 })
368 .ok();
369 }));
370 }
371
372 fn authorize_tool_call(
373 &mut self,
374 id: ToolCallId,
375 outcome: acp::ToolCallConfirmationOutcome,
376 cx: &mut Context<Self>,
377 ) {
378 let Some(thread) = self.thread() else {
379 return;
380 };
381 thread.update(cx, |thread, cx| {
382 thread.authorize_tool_call(id, outcome, cx);
383 });
384 cx.notify();
385 }
386
387 fn render_entry(
388 &self,
389 index: usize,
390 entry: &ThreadEntry,
391 window: &mut Window,
392 cx: &Context<Self>,
393 ) -> AnyElement {
394 match &entry.content {
395 AgentThreadEntryContent::Message(message) => {
396 let style = if message.role == Role::User {
397 user_message_markdown_style(window, cx)
398 } else {
399 default_markdown_style(window, cx)
400 };
401 let message_body = div()
402 .children(message.chunks.iter().map(|chunk| match chunk {
403 MessageChunk::Text { chunk } => {
404 // todo!() open link
405 MarkdownElement::new(chunk.clone(), style.clone())
406 }
407 _ => todo!(),
408 }))
409 .into_any();
410
411 match message.role {
412 Role::User => div()
413 .p_2()
414 .pt_5()
415 .child(
416 div()
417 .text_xs()
418 .p_3()
419 .bg(cx.theme().colors().editor_background)
420 .rounded_lg()
421 .shadow_md()
422 .border_1()
423 .border_color(cx.theme().colors().border)
424 .child(message_body),
425 )
426 .into_any(),
427 Role::Assistant => div()
428 .text_ui(cx)
429 .p_5()
430 .pt_2()
431 .child(message_body)
432 .into_any(),
433 }
434 }
435 AgentThreadEntryContent::ToolCall(tool_call) => div()
436 .px_2()
437 .py_4()
438 .child(self.render_tool_call(index, tool_call, window, cx))
439 .into_any(),
440 }
441 }
442
443 fn render_tool_call(
444 &self,
445 entry_ix: usize,
446 tool_call: &ToolCall,
447 window: &Window,
448 cx: &Context<Self>,
449 ) -> Div {
450 let status_icon = match &tool_call.status {
451 ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
452 ToolCallStatus::Allowed {
453 status: acp::ToolCallStatus::Running,
454 ..
455 } => Icon::new(IconName::ArrowCircle)
456 .color(Color::Success)
457 .size(IconSize::Small)
458 .with_animation(
459 "running",
460 Animation::new(Duration::from_secs(2)).repeat(),
461 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
462 )
463 .into_any_element(),
464 ToolCallStatus::Allowed {
465 status: acp::ToolCallStatus::Finished,
466 ..
467 } => Icon::new(IconName::Check)
468 .color(Color::Success)
469 .size(IconSize::Small)
470 .into_any_element(),
471 ToolCallStatus::Rejected
472 | ToolCallStatus::Canceled
473 | ToolCallStatus::Allowed {
474 status: acp::ToolCallStatus::Error,
475 ..
476 } => Icon::new(IconName::X)
477 .color(Color::Error)
478 .size(IconSize::Small)
479 .into_any_element(),
480 };
481
482 let content = match &tool_call.status {
483 ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
484 Some(self.render_tool_call_confirmation(
485 entry_ix,
486 tool_call.id,
487 confirmation,
488 tool_call.content.as_ref(),
489 window,
490 cx,
491 ))
492 }
493 ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
494 tool_call.content.as_ref().map(|content| {
495 div()
496 .border_color(cx.theme().colors().border)
497 .border_t_1()
498 .px_2()
499 .py_1p5()
500 .child(self.render_tool_call_content(entry_ix, content, window, cx))
501 .into_any_element()
502 })
503 }
504 ToolCallStatus::Rejected => None,
505 };
506
507 v_flex()
508 .text_xs()
509 .rounded_md()
510 .border_1()
511 .border_color(cx.theme().colors().border)
512 .bg(cx.theme().colors().editor_background)
513 .child(
514 h_flex()
515 .px_2()
516 .py_1p5()
517 .w_full()
518 .gap_1p5()
519 .child(
520 Icon::new(tool_call.icon)
521 .size(IconSize::Small)
522 .color(Color::Muted),
523 )
524 // todo! danilo please help
525 .child(MarkdownElement::new(
526 tool_call.label.clone(),
527 default_markdown_style(window, cx),
528 ))
529 .child(div().w_full())
530 .child(status_icon),
531 )
532 .children(content)
533 }
534
535 fn render_tool_call_content(
536 &self,
537 entry_ix: usize,
538 content: &ToolCallContent,
539 window: &Window,
540 cx: &Context<Self>,
541 ) -> AnyElement {
542 match content {
543 ToolCallContent::Markdown { markdown } => {
544 MarkdownElement::new(markdown.clone(), default_markdown_style(window, cx))
545 .into_any_element()
546 }
547 ToolCallContent::Diff {
548 diff: Diff { path, .. },
549 ..
550 } => self.render_diff_editor(entry_ix, path),
551 }
552 }
553
554 fn render_tool_call_confirmation(
555 &self,
556 entry_ix: usize,
557 tool_call_id: ToolCallId,
558 confirmation: &ToolCallConfirmation,
559 content: Option<&ToolCallContent>,
560 window: &Window,
561 cx: &Context<Self>,
562 ) -> AnyElement {
563 match confirmation {
564 ToolCallConfirmation::Edit { description } => {
565 v_flex()
566 .border_color(cx.theme().colors().border)
567 .border_t_1()
568 .px_2()
569 .py_1p5()
570 .children(description.clone().map(|description| {
571 MarkdownElement::new(description, default_markdown_style(window, cx))
572 }))
573 .children(content.map(|content| {
574 self.render_tool_call_content(entry_ix, content, window, cx)
575 }))
576 .child(
577 h_flex()
578 .justify_end()
579 .gap_1()
580 .child(
581 Button::new(
582 ("always_allow", tool_call_id.as_u64()),
583 "Always Allow Edits",
584 )
585 .icon(IconName::CheckDouble)
586 .icon_position(IconPosition::Start)
587 .icon_size(IconSize::Small)
588 .icon_color(Color::Success)
589 .on_click(cx.listener({
590 let id = tool_call_id;
591 move |this, _, _, cx| {
592 this.authorize_tool_call(
593 id,
594 acp::ToolCallConfirmationOutcome::AlwaysAllow,
595 cx,
596 );
597 }
598 })),
599 )
600 .child(
601 Button::new(("allow", tool_call_id.as_u64()), "Allow")
602 .icon(IconName::Check)
603 .icon_position(IconPosition::Start)
604 .icon_size(IconSize::Small)
605 .icon_color(Color::Success)
606 .on_click(cx.listener({
607 let id = tool_call_id;
608 move |this, _, _, cx| {
609 this.authorize_tool_call(
610 id,
611 acp::ToolCallConfirmationOutcome::Allow,
612 cx,
613 );
614 }
615 })),
616 )
617 .child(
618 Button::new(("reject", tool_call_id.as_u64()), "Reject")
619 .icon(IconName::X)
620 .icon_position(IconPosition::Start)
621 .icon_size(IconSize::Small)
622 .icon_color(Color::Error)
623 .on_click(cx.listener({
624 let id = tool_call_id;
625 move |this, _, _, cx| {
626 this.authorize_tool_call(
627 id,
628 acp::ToolCallConfirmationOutcome::Reject,
629 cx,
630 );
631 }
632 })),
633 ),
634 )
635 .into_any()
636 }
637 ToolCallConfirmation::Execute {
638 command,
639 root_command,
640 description,
641 } => {
642 v_flex()
643 .border_color(cx.theme().colors().border)
644 .border_t_1()
645 .px_2()
646 .py_1p5()
647 // todo! nicer rendering
648 .child(command.clone())
649 .children(description.clone().map(|description| {
650 MarkdownElement::new(description, default_markdown_style(window, cx))
651 }))
652 .children(content.map(|content| {
653 self.render_tool_call_content(entry_ix, content, window, cx)
654 }))
655 .child(
656 h_flex()
657 .justify_end()
658 .gap_1()
659 .child(
660 Button::new(
661 ("always_allow", tool_call_id.as_u64()),
662 format!("Always Allow {root_command}"),
663 )
664 .icon(IconName::CheckDouble)
665 .icon_position(IconPosition::Start)
666 .icon_size(IconSize::Small)
667 .icon_color(Color::Success)
668 .on_click(cx.listener({
669 let id = tool_call_id;
670 move |this, _, _, cx| {
671 this.authorize_tool_call(
672 id,
673 acp::ToolCallConfirmationOutcome::AlwaysAllow,
674 cx,
675 );
676 }
677 })),
678 )
679 .child(
680 Button::new(("allow", tool_call_id.as_u64()), "Allow")
681 .icon(IconName::Check)
682 .icon_position(IconPosition::Start)
683 .icon_size(IconSize::Small)
684 .icon_color(Color::Success)
685 .on_click(cx.listener({
686 let id = tool_call_id;
687 move |this, _, _, cx| {
688 this.authorize_tool_call(
689 id,
690 acp::ToolCallConfirmationOutcome::Allow,
691 cx,
692 );
693 }
694 })),
695 )
696 .child(
697 Button::new(("reject", tool_call_id.as_u64()), "Reject")
698 .icon(IconName::X)
699 .icon_position(IconPosition::Start)
700 .icon_size(IconSize::Small)
701 .icon_color(Color::Error)
702 .on_click(cx.listener({
703 let id = tool_call_id;
704 move |this, _, _, cx| {
705 this.authorize_tool_call(
706 id,
707 acp::ToolCallConfirmationOutcome::Reject,
708 cx,
709 );
710 }
711 })),
712 ),
713 )
714 .into_any()
715 }
716 ToolCallConfirmation::Mcp {
717 server_name,
718 tool_name: _,
719 tool_display_name,
720 description,
721 } => {
722 v_flex()
723 .border_color(cx.theme().colors().border)
724 .border_t_1()
725 .px_2()
726 .py_1p5()
727 // todo! nicer rendering
728 .child(format!("{server_name} - {tool_display_name}"))
729 .children(description.clone().map(|description| {
730 MarkdownElement::new(description, default_markdown_style(window, cx))
731 }))
732 .children(content.map(|content| {
733 self.render_tool_call_content(entry_ix, content, window, cx)
734 }))
735 .child(
736 h_flex()
737 .justify_end()
738 .gap_1()
739 .child(
740 Button::new(
741 ("always_allow_server", tool_call_id.as_u64()),
742 format!("Always Allow {server_name}"),
743 )
744 .icon(IconName::CheckDouble)
745 .icon_position(IconPosition::Start)
746 .icon_size(IconSize::Small)
747 .icon_color(Color::Success)
748 .on_click(cx.listener({
749 let id = tool_call_id;
750 move |this, _, _, cx| {
751 this.authorize_tool_call(
752 id,
753 acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
754 cx,
755 );
756 }
757 })),
758 )
759 .child(
760 Button::new(
761 ("always_allow_tool", tool_call_id.as_u64()),
762 format!("Always Allow {tool_display_name}"),
763 )
764 .icon(IconName::CheckDouble)
765 .icon_position(IconPosition::Start)
766 .icon_size(IconSize::Small)
767 .icon_color(Color::Success)
768 .on_click(cx.listener({
769 let id = tool_call_id;
770 move |this, _, _, cx| {
771 this.authorize_tool_call(
772 id,
773 acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
774 cx,
775 );
776 }
777 })),
778 )
779 .child(
780 Button::new(("allow", tool_call_id.as_u64()), "Allow")
781 .icon(IconName::Check)
782 .icon_position(IconPosition::Start)
783 .icon_size(IconSize::Small)
784 .icon_color(Color::Success)
785 .on_click(cx.listener({
786 let id = tool_call_id;
787 move |this, _, _, cx| {
788 this.authorize_tool_call(
789 id,
790 acp::ToolCallConfirmationOutcome::Allow,
791 cx,
792 );
793 }
794 })),
795 )
796 .child(
797 Button::new(("reject", tool_call_id.as_u64()), "Reject")
798 .icon(IconName::X)
799 .icon_position(IconPosition::Start)
800 .icon_size(IconSize::Small)
801 .icon_color(Color::Error)
802 .on_click(cx.listener({
803 let id = tool_call_id;
804 move |this, _, _, cx| {
805 this.authorize_tool_call(
806 id,
807 acp::ToolCallConfirmationOutcome::Reject,
808 cx,
809 );
810 }
811 })),
812 ),
813 )
814 .into_any()
815 }
816 ToolCallConfirmation::Fetch { description, urls } => v_flex()
817 .border_color(cx.theme().colors().border)
818 .border_t_1()
819 .px_2()
820 .py_1p5()
821 // todo! nicer rendering
822 .children(urls.clone())
823 .children(description.clone().map(|description| {
824 MarkdownElement::new(description, default_markdown_style(window, cx))
825 }))
826 .children(
827 content.map(|content| {
828 self.render_tool_call_content(entry_ix, content, window, cx)
829 }),
830 )
831 .child(
832 h_flex()
833 .justify_end()
834 .gap_1()
835 .child(
836 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
837 .icon(IconName::CheckDouble)
838 .icon_position(IconPosition::Start)
839 .icon_size(IconSize::Small)
840 .icon_color(Color::Success)
841 .on_click(cx.listener({
842 let id = tool_call_id;
843 move |this, _, _, cx| {
844 this.authorize_tool_call(
845 id,
846 acp::ToolCallConfirmationOutcome::AlwaysAllow,
847 cx,
848 );
849 }
850 })),
851 )
852 .child(
853 Button::new(("allow", tool_call_id.as_u64()), "Allow")
854 .icon(IconName::Check)
855 .icon_position(IconPosition::Start)
856 .icon_size(IconSize::Small)
857 .icon_color(Color::Success)
858 .on_click(cx.listener({
859 let id = tool_call_id;
860 move |this, _, _, cx| {
861 this.authorize_tool_call(
862 id,
863 acp::ToolCallConfirmationOutcome::Allow,
864 cx,
865 );
866 }
867 })),
868 )
869 .child(
870 Button::new(("reject", tool_call_id.as_u64()), "Reject")
871 .icon(IconName::X)
872 .icon_position(IconPosition::Start)
873 .icon_size(IconSize::Small)
874 .icon_color(Color::Error)
875 .on_click(cx.listener({
876 let id = tool_call_id;
877 move |this, _, _, cx| {
878 this.authorize_tool_call(
879 id,
880 acp::ToolCallConfirmationOutcome::Reject,
881 cx,
882 );
883 }
884 })),
885 ),
886 )
887 .into_any(),
888 ToolCallConfirmation::Other { description } => v_flex()
889 .border_color(cx.theme().colors().border)
890 .border_t_1()
891 .px_2()
892 .py_1p5()
893 // todo! nicer rendering
894 .child(MarkdownElement::new(
895 description.clone(),
896 default_markdown_style(window, cx),
897 ))
898 .children(
899 content.map(|content| {
900 self.render_tool_call_content(entry_ix, content, window, cx)
901 }),
902 )
903 .child(
904 h_flex()
905 .justify_end()
906 .gap_1()
907 .child(
908 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
909 .icon(IconName::CheckDouble)
910 .icon_position(IconPosition::Start)
911 .icon_size(IconSize::Small)
912 .icon_color(Color::Success)
913 .on_click(cx.listener({
914 let id = tool_call_id;
915 move |this, _, _, cx| {
916 this.authorize_tool_call(
917 id,
918 acp::ToolCallConfirmationOutcome::AlwaysAllow,
919 cx,
920 );
921 }
922 })),
923 )
924 .child(
925 Button::new(("allow", tool_call_id.as_u64()), "Allow")
926 .icon(IconName::Check)
927 .icon_position(IconPosition::Start)
928 .icon_size(IconSize::Small)
929 .icon_color(Color::Success)
930 .on_click(cx.listener({
931 let id = tool_call_id;
932 move |this, _, _, cx| {
933 this.authorize_tool_call(
934 id,
935 acp::ToolCallConfirmationOutcome::Allow,
936 cx,
937 );
938 }
939 })),
940 )
941 .child(
942 Button::new(("reject", tool_call_id.as_u64()), "Reject")
943 .icon(IconName::X)
944 .icon_position(IconPosition::Start)
945 .icon_size(IconSize::Small)
946 .icon_color(Color::Error)
947 .on_click(cx.listener({
948 let id = tool_call_id;
949 move |this, _, _, cx| {
950 this.authorize_tool_call(
951 id,
952 acp::ToolCallConfirmationOutcome::Reject,
953 cx,
954 );
955 }
956 })),
957 ),
958 )
959 .into_any(),
960 }
961 }
962
963 fn render_diff_editor(&self, entry_ix: usize, path: &Path) -> AnyElement {
964 v_flex()
965 .h_full()
966 .child(path.to_string_lossy().to_string())
967 .child(
968 if let Some(Some(ThreadEntryView::Diff { editor })) =
969 self.thread_entry_views.get(entry_ix)
970 {
971 editor.clone().into_any_element()
972 } else {
973 Empty.into_any()
974 },
975 )
976 .into_any()
977 }
978}
979
980impl Focusable for AcpThreadView {
981 fn focus_handle(&self, cx: &App) -> FocusHandle {
982 self.message_editor.focus_handle(cx)
983 }
984}
985
986impl Render for AcpThreadView {
987 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
988 let text = self.message_editor.read(cx).text(cx);
989 let is_editor_empty = text.is_empty();
990 let focus_handle = self.message_editor.focus_handle(cx);
991
992 v_flex()
993 .key_context("MessageEditor")
994 .on_action(cx.listener(Self::chat))
995 .h_full()
996 .child(match &self.thread_state {
997 ThreadState::Unauthenticated => v_flex()
998 .p_2()
999 .flex_1()
1000 .justify_end()
1001 .child(Label::new("Not authenticated"))
1002 .child(Button::new("sign-in", "Sign in via Gemini CLI").on_click(
1003 cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
1004 )),
1005 ThreadState::Loading { .. } => v_flex()
1006 .p_2()
1007 .flex_1()
1008 .justify_end()
1009 .child(Label::new("Connecting to Gemini...")),
1010 ThreadState::LoadError(e) => div()
1011 .p_2()
1012 .flex_1()
1013 .justify_end()
1014 .child(Label::new(format!("Failed to load: {e}")).into_any_element()),
1015 ThreadState::Ready { thread, .. } => v_flex()
1016 .flex_1()
1017 .gap_2()
1018 .pb_2()
1019 .child(
1020 list(self.list_state.clone())
1021 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
1022 .flex_grow(),
1023 )
1024 .child(
1025 div().px_3().children(match thread.read(cx).status() {
1026 ThreadStatus::Idle => None,
1027 ThreadStatus::WaitingForToolConfirmation => {
1028 Label::new("Waiting for tool confirmation")
1029 .color(Color::Muted)
1030 .size(LabelSize::Small)
1031 .into()
1032 }
1033 ThreadStatus::Generating => Label::new("Generating...")
1034 .color(Color::Muted)
1035 .size(LabelSize::Small)
1036 .into(),
1037 }),
1038 ),
1039 })
1040 .when_some(self.last_error.clone(), |el, error| {
1041 el.child(
1042 div()
1043 .text_xs()
1044 .p_2()
1045 .gap_2()
1046 .border_t_1()
1047 .border_color(cx.theme().status().error_border)
1048 .bg(cx.theme().status().error_background)
1049 .child(MarkdownElement::new(
1050 error,
1051 default_markdown_style(window, cx),
1052 )),
1053 )
1054 })
1055 .child(
1056 v_flex()
1057 .bg(cx.theme().colors().editor_background)
1058 .border_t_1()
1059 .border_color(cx.theme().colors().border)
1060 .p_2()
1061 .gap_2()
1062 .child(self.message_editor.clone())
1063 .child({
1064 let thread = self.thread();
1065
1066 h_flex().justify_end().child(
1067 if thread.map_or(true, |thread| {
1068 thread.read(cx).status() == ThreadStatus::Idle
1069 }) {
1070 IconButton::new("send-message", IconName::Send)
1071 .icon_color(Color::Accent)
1072 .style(ButtonStyle::Filled)
1073 .disabled(thread.is_none() || is_editor_empty)
1074 .on_click({
1075 let focus_handle = focus_handle.clone();
1076 move |_event, window, cx| {
1077 focus_handle.dispatch_action(&Chat, window, cx);
1078 }
1079 })
1080 .when(!is_editor_empty, |button| {
1081 button.tooltip(move |window, cx| {
1082 Tooltip::for_action("Send", &Chat, window, cx)
1083 })
1084 })
1085 .when(is_editor_empty, |button| {
1086 button.tooltip(Tooltip::text("Type a message to submit"))
1087 })
1088 } else {
1089 IconButton::new("stop-generation", IconName::StopFilled)
1090 .icon_color(Color::Error)
1091 .style(ButtonStyle::Tinted(ui::TintColor::Error))
1092 .tooltip(move |window, cx| {
1093 Tooltip::for_action(
1094 "Stop Generation",
1095 &editor::actions::Cancel,
1096 window,
1097 cx,
1098 )
1099 })
1100 .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
1101 },
1102 )
1103 }),
1104 )
1105 }
1106}
1107
1108fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1109 let mut style = default_markdown_style(window, cx);
1110 let mut text_style = window.text_style();
1111 let theme_settings = ThemeSettings::get_global(cx);
1112
1113 let buffer_font = theme_settings.buffer_font.family.clone();
1114 let buffer_font_size = TextSize::Small.rems(cx);
1115
1116 text_style.refine(&TextStyleRefinement {
1117 font_family: Some(buffer_font),
1118 font_size: Some(buffer_font_size.into()),
1119 ..Default::default()
1120 });
1121
1122 style.base_text_style = text_style;
1123 style
1124}
1125
1126fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1127 let theme_settings = ThemeSettings::get_global(cx);
1128 let colors = cx.theme().colors();
1129 let ui_font_size = TextSize::Default.rems(cx);
1130 let buffer_font_size = TextSize::Small.rems(cx);
1131 let mut text_style = window.text_style();
1132 let line_height = buffer_font_size * 1.75;
1133
1134 text_style.refine(&TextStyleRefinement {
1135 font_family: Some(theme_settings.ui_font.family.clone()),
1136 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1137 font_features: Some(theme_settings.ui_font.features.clone()),
1138 font_size: Some(ui_font_size.into()),
1139 line_height: Some(line_height.into()),
1140 color: Some(cx.theme().colors().text),
1141 ..Default::default()
1142 });
1143
1144 MarkdownStyle {
1145 base_text_style: text_style.clone(),
1146 syntax: cx.theme().syntax().clone(),
1147 selection_background_color: cx.theme().colors().element_selection_background,
1148 code_block_overflow_x_scroll: true,
1149 table_overflow_x_scroll: true,
1150 heading_level_styles: Some(HeadingLevelStyles {
1151 h1: Some(TextStyleRefinement {
1152 font_size: Some(rems(1.15).into()),
1153 ..Default::default()
1154 }),
1155 h2: Some(TextStyleRefinement {
1156 font_size: Some(rems(1.1).into()),
1157 ..Default::default()
1158 }),
1159 h3: Some(TextStyleRefinement {
1160 font_size: Some(rems(1.05).into()),
1161 ..Default::default()
1162 }),
1163 h4: Some(TextStyleRefinement {
1164 font_size: Some(rems(1.).into()),
1165 ..Default::default()
1166 }),
1167 h5: Some(TextStyleRefinement {
1168 font_size: Some(rems(0.95).into()),
1169 ..Default::default()
1170 }),
1171 h6: Some(TextStyleRefinement {
1172 font_size: Some(rems(0.875).into()),
1173 ..Default::default()
1174 }),
1175 }),
1176 code_block: StyleRefinement {
1177 padding: EdgesRefinement {
1178 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1179 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1180 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1181 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1182 },
1183 background: Some(colors.editor_background.into()),
1184 text: Some(TextStyleRefinement {
1185 font_family: Some(theme_settings.buffer_font.family.clone()),
1186 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1187 font_features: Some(theme_settings.buffer_font.features.clone()),
1188 font_size: Some(buffer_font_size.into()),
1189 ..Default::default()
1190 }),
1191 ..Default::default()
1192 },
1193 inline_code: TextStyleRefinement {
1194 font_family: Some(theme_settings.buffer_font.family.clone()),
1195 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1196 font_features: Some(theme_settings.buffer_font.features.clone()),
1197 font_size: Some(buffer_font_size.into()),
1198 background_color: Some(colors.editor_foreground.opacity(0.08)),
1199 ..Default::default()
1200 },
1201 link: TextStyleRefinement {
1202 background_color: Some(colors.editor_foreground.opacity(0.025)),
1203 underline: Some(UnderlineStyle {
1204 color: Some(colors.text_accent.opacity(0.5)),
1205 thickness: px(1.),
1206 ..Default::default()
1207 }),
1208 ..Default::default()
1209 },
1210 link_callback: Some(Rc::new(move |_url, _cx| {
1211 // todo!()
1212 // if MentionLink::is_valid(url) {
1213 // let colors = cx.theme().colors();
1214 // Some(TextStyleRefinement {
1215 // background_color: Some(colors.element_background),
1216 // ..Default::default()
1217 // })
1218 // } else {
1219 None
1220 // }
1221 })),
1222 ..Default::default()
1223 }
1224}