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