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, Diff, MessageChunk, Role,
28 ThreadEntry, 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 {
543 diff: Diff { path, .. },
544 ..
545 } => self.render_diff_editor(entry_ix, path),
546 }
547 }
548
549 fn render_tool_call_confirmation(
550 &self,
551 entry_ix: usize,
552 tool_call_id: ToolCallId,
553 confirmation: &ToolCallConfirmation,
554 content: Option<&ToolCallContent>,
555 window: &Window,
556 cx: &Context<Self>,
557 ) -> AnyElement {
558 match confirmation {
559 ToolCallConfirmation::Edit { description } => {
560 v_flex()
561 .border_color(cx.theme().colors().border)
562 .border_t_1()
563 .px_2()
564 .py_1p5()
565 .children(description.clone().map(|description| {
566 MarkdownElement::new(description, default_markdown_style(window, cx))
567 }))
568 .children(content.map(|content| {
569 self.render_tool_call_content(entry_ix, content, window, cx)
570 }))
571 .child(
572 h_flex()
573 .justify_end()
574 .gap_1()
575 .child(
576 Button::new(
577 ("always_allow", tool_call_id.as_u64()),
578 "Always Allow Edits",
579 )
580 .icon(IconName::CheckDouble)
581 .icon_position(IconPosition::Start)
582 .icon_size(IconSize::Small)
583 .icon_color(Color::Success)
584 .on_click(cx.listener({
585 let id = tool_call_id;
586 move |this, _, _, cx| {
587 this.authorize_tool_call(
588 id,
589 acp::ToolCallConfirmationOutcome::AlwaysAllow,
590 cx,
591 );
592 }
593 })),
594 )
595 .child(
596 Button::new(("allow", tool_call_id.as_u64()), "Allow")
597 .icon(IconName::Check)
598 .icon_position(IconPosition::Start)
599 .icon_size(IconSize::Small)
600 .icon_color(Color::Success)
601 .on_click(cx.listener({
602 let id = tool_call_id;
603 move |this, _, _, cx| {
604 this.authorize_tool_call(
605 id,
606 acp::ToolCallConfirmationOutcome::Allow,
607 cx,
608 );
609 }
610 })),
611 )
612 .child(
613 Button::new(("reject", tool_call_id.as_u64()), "Reject")
614 .icon(IconName::X)
615 .icon_position(IconPosition::Start)
616 .icon_size(IconSize::Small)
617 .icon_color(Color::Error)
618 .on_click(cx.listener({
619 let id = tool_call_id;
620 move |this, _, _, cx| {
621 this.authorize_tool_call(
622 id,
623 acp::ToolCallConfirmationOutcome::Reject,
624 cx,
625 );
626 }
627 })),
628 ),
629 )
630 .into_any()
631 }
632 ToolCallConfirmation::Execute {
633 command,
634 root_command,
635 description,
636 } => {
637 v_flex()
638 .border_color(cx.theme().colors().border)
639 .border_t_1()
640 .px_2()
641 .py_1p5()
642 // todo! nicer rendering
643 .child(command.clone())
644 .children(description.clone().map(|description| {
645 MarkdownElement::new(description, default_markdown_style(window, cx))
646 }))
647 .children(content.map(|content| {
648 self.render_tool_call_content(entry_ix, content, window, cx)
649 }))
650 .child(
651 h_flex()
652 .justify_end()
653 .gap_1()
654 .child(
655 Button::new(
656 ("always_allow", tool_call_id.as_u64()),
657 format!("Always Allow {root_command}"),
658 )
659 .icon(IconName::CheckDouble)
660 .icon_position(IconPosition::Start)
661 .icon_size(IconSize::Small)
662 .icon_color(Color::Success)
663 .on_click(cx.listener({
664 let id = tool_call_id;
665 move |this, _, _, cx| {
666 this.authorize_tool_call(
667 id,
668 acp::ToolCallConfirmationOutcome::AlwaysAllow,
669 cx,
670 );
671 }
672 })),
673 )
674 .child(
675 Button::new(("allow", tool_call_id.as_u64()), "Allow")
676 .icon(IconName::Check)
677 .icon_position(IconPosition::Start)
678 .icon_size(IconSize::Small)
679 .icon_color(Color::Success)
680 .on_click(cx.listener({
681 let id = tool_call_id;
682 move |this, _, _, cx| {
683 this.authorize_tool_call(
684 id,
685 acp::ToolCallConfirmationOutcome::Allow,
686 cx,
687 );
688 }
689 })),
690 )
691 .child(
692 Button::new(("reject", tool_call_id.as_u64()), "Reject")
693 .icon(IconName::X)
694 .icon_position(IconPosition::Start)
695 .icon_size(IconSize::Small)
696 .icon_color(Color::Error)
697 .on_click(cx.listener({
698 let id = tool_call_id;
699 move |this, _, _, cx| {
700 this.authorize_tool_call(
701 id,
702 acp::ToolCallConfirmationOutcome::Reject,
703 cx,
704 );
705 }
706 })),
707 ),
708 )
709 .into_any()
710 }
711 ToolCallConfirmation::Mcp {
712 server_name,
713 tool_name: _,
714 tool_display_name,
715 description,
716 } => {
717 v_flex()
718 .border_color(cx.theme().colors().border)
719 .border_t_1()
720 .px_2()
721 .py_1p5()
722 // todo! nicer rendering
723 .child(format!("{server_name} - {tool_display_name}"))
724 .children(description.clone().map(|description| {
725 MarkdownElement::new(description, default_markdown_style(window, cx))
726 }))
727 .children(content.map(|content| {
728 self.render_tool_call_content(entry_ix, content, window, cx)
729 }))
730 .child(
731 h_flex()
732 .justify_end()
733 .gap_1()
734 .child(
735 Button::new(
736 ("always_allow_server", tool_call_id.as_u64()),
737 format!("Always Allow {server_name}"),
738 )
739 .icon(IconName::CheckDouble)
740 .icon_position(IconPosition::Start)
741 .icon_size(IconSize::Small)
742 .icon_color(Color::Success)
743 .on_click(cx.listener({
744 let id = tool_call_id;
745 move |this, _, _, cx| {
746 this.authorize_tool_call(
747 id,
748 acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
749 cx,
750 );
751 }
752 })),
753 )
754 .child(
755 Button::new(
756 ("always_allow_tool", tool_call_id.as_u64()),
757 format!("Always Allow {tool_display_name}"),
758 )
759 .icon(IconName::CheckDouble)
760 .icon_position(IconPosition::Start)
761 .icon_size(IconSize::Small)
762 .icon_color(Color::Success)
763 .on_click(cx.listener({
764 let id = tool_call_id;
765 move |this, _, _, cx| {
766 this.authorize_tool_call(
767 id,
768 acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
769 cx,
770 );
771 }
772 })),
773 )
774 .child(
775 Button::new(("allow", tool_call_id.as_u64()), "Allow")
776 .icon(IconName::Check)
777 .icon_position(IconPosition::Start)
778 .icon_size(IconSize::Small)
779 .icon_color(Color::Success)
780 .on_click(cx.listener({
781 let id = tool_call_id;
782 move |this, _, _, cx| {
783 this.authorize_tool_call(
784 id,
785 acp::ToolCallConfirmationOutcome::Allow,
786 cx,
787 );
788 }
789 })),
790 )
791 .child(
792 Button::new(("reject", tool_call_id.as_u64()), "Reject")
793 .icon(IconName::X)
794 .icon_position(IconPosition::Start)
795 .icon_size(IconSize::Small)
796 .icon_color(Color::Error)
797 .on_click(cx.listener({
798 let id = tool_call_id;
799 move |this, _, _, cx| {
800 this.authorize_tool_call(
801 id,
802 acp::ToolCallConfirmationOutcome::Reject,
803 cx,
804 );
805 }
806 })),
807 ),
808 )
809 .into_any()
810 }
811 ToolCallConfirmation::Fetch { description, urls } => v_flex()
812 .border_color(cx.theme().colors().border)
813 .border_t_1()
814 .px_2()
815 .py_1p5()
816 // todo! nicer rendering
817 .children(urls.clone())
818 .children(description.clone().map(|description| {
819 MarkdownElement::new(description, default_markdown_style(window, cx))
820 }))
821 .children(
822 content.map(|content| {
823 self.render_tool_call_content(entry_ix, content, window, cx)
824 }),
825 )
826 .child(
827 h_flex()
828 .justify_end()
829 .gap_1()
830 .child(
831 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
832 .icon(IconName::CheckDouble)
833 .icon_position(IconPosition::Start)
834 .icon_size(IconSize::Small)
835 .icon_color(Color::Success)
836 .on_click(cx.listener({
837 let id = tool_call_id;
838 move |this, _, _, cx| {
839 this.authorize_tool_call(
840 id,
841 acp::ToolCallConfirmationOutcome::AlwaysAllow,
842 cx,
843 );
844 }
845 })),
846 )
847 .child(
848 Button::new(("allow", tool_call_id.as_u64()), "Allow")
849 .icon(IconName::Check)
850 .icon_position(IconPosition::Start)
851 .icon_size(IconSize::Small)
852 .icon_color(Color::Success)
853 .on_click(cx.listener({
854 let id = tool_call_id;
855 move |this, _, _, cx| {
856 this.authorize_tool_call(
857 id,
858 acp::ToolCallConfirmationOutcome::Allow,
859 cx,
860 );
861 }
862 })),
863 )
864 .child(
865 Button::new(("reject", tool_call_id.as_u64()), "Reject")
866 .icon(IconName::X)
867 .icon_position(IconPosition::Start)
868 .icon_size(IconSize::Small)
869 .icon_color(Color::Error)
870 .on_click(cx.listener({
871 let id = tool_call_id;
872 move |this, _, _, cx| {
873 this.authorize_tool_call(
874 id,
875 acp::ToolCallConfirmationOutcome::Reject,
876 cx,
877 );
878 }
879 })),
880 ),
881 )
882 .into_any(),
883 ToolCallConfirmation::Other { description } => v_flex()
884 .border_color(cx.theme().colors().border)
885 .border_t_1()
886 .px_2()
887 .py_1p5()
888 // todo! nicer rendering
889 .child(MarkdownElement::new(
890 description.clone(),
891 default_markdown_style(window, cx),
892 ))
893 .children(
894 content.map(|content| {
895 self.render_tool_call_content(entry_ix, content, window, cx)
896 }),
897 )
898 .child(
899 h_flex()
900 .justify_end()
901 .gap_1()
902 .child(
903 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
904 .icon(IconName::CheckDouble)
905 .icon_position(IconPosition::Start)
906 .icon_size(IconSize::Small)
907 .icon_color(Color::Success)
908 .on_click(cx.listener({
909 let id = tool_call_id;
910 move |this, _, _, cx| {
911 this.authorize_tool_call(
912 id,
913 acp::ToolCallConfirmationOutcome::AlwaysAllow,
914 cx,
915 );
916 }
917 })),
918 )
919 .child(
920 Button::new(("allow", tool_call_id.as_u64()), "Allow")
921 .icon(IconName::Check)
922 .icon_position(IconPosition::Start)
923 .icon_size(IconSize::Small)
924 .icon_color(Color::Success)
925 .on_click(cx.listener({
926 let id = tool_call_id;
927 move |this, _, _, cx| {
928 this.authorize_tool_call(
929 id,
930 acp::ToolCallConfirmationOutcome::Allow,
931 cx,
932 );
933 }
934 })),
935 )
936 .child(
937 Button::new(("reject", tool_call_id.as_u64()), "Reject")
938 .icon(IconName::X)
939 .icon_position(IconPosition::Start)
940 .icon_size(IconSize::Small)
941 .icon_color(Color::Error)
942 .on_click(cx.listener({
943 let id = tool_call_id;
944 move |this, _, _, cx| {
945 this.authorize_tool_call(
946 id,
947 acp::ToolCallConfirmationOutcome::Reject,
948 cx,
949 );
950 }
951 })),
952 ),
953 )
954 .into_any(),
955 }
956 }
957
958 fn render_diff_editor(&self, entry_ix: usize, path: &Path) -> AnyElement {
959 v_flex()
960 .h_full()
961 .child(path.to_string_lossy().to_string())
962 .child(
963 if let Some(Some(ThreadEntryView::Diff { editor })) =
964 self.thread_entry_views.get(entry_ix)
965 {
966 editor.clone().into_any_element()
967 } else {
968 Empty.into_any()
969 },
970 )
971 .into_any()
972 }
973}
974
975impl Focusable for AcpThreadView {
976 fn focus_handle(&self, cx: &App) -> FocusHandle {
977 self.message_editor.focus_handle(cx)
978 }
979}
980
981impl Render for AcpThreadView {
982 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
983 let text = self.message_editor.read(cx).text(cx);
984 let is_editor_empty = text.is_empty();
985 let focus_handle = self.message_editor.focus_handle(cx);
986
987 v_flex()
988 .key_context("MessageEditor")
989 .on_action(cx.listener(Self::chat))
990 .h_full()
991 .child(match &self.thread_state {
992 ThreadState::Unauthenticated => v_flex()
993 .p_2()
994 .flex_1()
995 .justify_end()
996 .child(Label::new("Not authenticated"))
997 .child(Button::new("sign-in", "Sign in via Gemini CLI").on_click(
998 cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
999 )),
1000 ThreadState::Loading { .. } => v_flex()
1001 .p_2()
1002 .flex_1()
1003 .justify_end()
1004 .child(Label::new("Connecting to Gemini...")),
1005 ThreadState::LoadError(e) => div()
1006 .p_2()
1007 .flex_1()
1008 .justify_end()
1009 .child(Label::new(format!("Failed to load: {e}")).into_any_element()),
1010 ThreadState::Ready { thread, .. } => v_flex()
1011 .flex_1()
1012 .gap_2()
1013 .pb_2()
1014 .child(
1015 list(self.list_state.clone())
1016 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
1017 .flex_grow(),
1018 )
1019 .child(div().px_3().children(if self.send_task.is_none() {
1020 None
1021 } else {
1022 Label::new(if thread.read(cx).waiting_for_tool_confirmation() {
1023 "Waiting for tool confirmation"
1024 } else {
1025 "Generating..."
1026 })
1027 .color(Color::Muted)
1028 .size(LabelSize::Small)
1029 .into()
1030 })),
1031 })
1032 .when_some(self.last_error.clone(), |el, error| {
1033 el.child(
1034 div()
1035 .text_xs()
1036 .p_2()
1037 .gap_2()
1038 .border_t_1()
1039 .border_color(cx.theme().status().error_border)
1040 .bg(cx.theme().status().error_background)
1041 .child(MarkdownElement::new(
1042 error,
1043 default_markdown_style(window, cx),
1044 )),
1045 )
1046 })
1047 .child(
1048 v_flex()
1049 .bg(cx.theme().colors().editor_background)
1050 .border_t_1()
1051 .border_color(cx.theme().colors().border)
1052 .p_2()
1053 .gap_2()
1054 .child(self.message_editor.clone())
1055 .child(h_flex().justify_end().child(if self.send_task.is_some() {
1056 IconButton::new("stop-generation", IconName::StopFilled)
1057 .icon_color(Color::Error)
1058 .style(ButtonStyle::Tinted(ui::TintColor::Error))
1059 .tooltip(move |window, cx| {
1060 Tooltip::for_action(
1061 "Stop Generation",
1062 &editor::actions::Cancel,
1063 window,
1064 cx,
1065 )
1066 })
1067 .disabled(is_editor_empty)
1068 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
1069 } else {
1070 IconButton::new("send-message", IconName::Send)
1071 .icon_color(Color::Accent)
1072 .style(ButtonStyle::Filled)
1073 .disabled(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 })),
1089 )
1090 }
1091}
1092
1093fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1094 let mut style = default_markdown_style(window, cx);
1095 let mut text_style = window.text_style();
1096 let theme_settings = ThemeSettings::get_global(cx);
1097
1098 let buffer_font = theme_settings.buffer_font.family.clone();
1099 let buffer_font_size = TextSize::Small.rems(cx);
1100
1101 text_style.refine(&TextStyleRefinement {
1102 font_family: Some(buffer_font),
1103 font_size: Some(buffer_font_size.into()),
1104 ..Default::default()
1105 });
1106
1107 style.base_text_style = text_style;
1108 style
1109}
1110
1111fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1112 let theme_settings = ThemeSettings::get_global(cx);
1113 let colors = cx.theme().colors();
1114 let ui_font_size = TextSize::Default.rems(cx);
1115 let buffer_font_size = TextSize::Small.rems(cx);
1116 let mut text_style = window.text_style();
1117 let line_height = buffer_font_size * 1.75;
1118
1119 text_style.refine(&TextStyleRefinement {
1120 font_family: Some(theme_settings.ui_font.family.clone()),
1121 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1122 font_features: Some(theme_settings.ui_font.features.clone()),
1123 font_size: Some(ui_font_size.into()),
1124 line_height: Some(line_height.into()),
1125 color: Some(cx.theme().colors().text),
1126 ..Default::default()
1127 });
1128
1129 MarkdownStyle {
1130 base_text_style: text_style.clone(),
1131 syntax: cx.theme().syntax().clone(),
1132 selection_background_color: cx.theme().colors().element_selection_background,
1133 code_block_overflow_x_scroll: true,
1134 table_overflow_x_scroll: true,
1135 heading_level_styles: Some(HeadingLevelStyles {
1136 h1: Some(TextStyleRefinement {
1137 font_size: Some(rems(1.15).into()),
1138 ..Default::default()
1139 }),
1140 h2: Some(TextStyleRefinement {
1141 font_size: Some(rems(1.1).into()),
1142 ..Default::default()
1143 }),
1144 h3: Some(TextStyleRefinement {
1145 font_size: Some(rems(1.05).into()),
1146 ..Default::default()
1147 }),
1148 h4: Some(TextStyleRefinement {
1149 font_size: Some(rems(1.).into()),
1150 ..Default::default()
1151 }),
1152 h5: Some(TextStyleRefinement {
1153 font_size: Some(rems(0.95).into()),
1154 ..Default::default()
1155 }),
1156 h6: Some(TextStyleRefinement {
1157 font_size: Some(rems(0.875).into()),
1158 ..Default::default()
1159 }),
1160 }),
1161 code_block: StyleRefinement {
1162 padding: EdgesRefinement {
1163 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1164 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1165 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1166 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1167 },
1168 background: Some(colors.editor_background.into()),
1169 text: Some(TextStyleRefinement {
1170 font_family: Some(theme_settings.buffer_font.family.clone()),
1171 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1172 font_features: Some(theme_settings.buffer_font.features.clone()),
1173 font_size: Some(buffer_font_size.into()),
1174 ..Default::default()
1175 }),
1176 ..Default::default()
1177 },
1178 inline_code: TextStyleRefinement {
1179 font_family: Some(theme_settings.buffer_font.family.clone()),
1180 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1181 font_features: Some(theme_settings.buffer_font.features.clone()),
1182 font_size: Some(buffer_font_size.into()),
1183 background_color: Some(colors.editor_foreground.opacity(0.08)),
1184 ..Default::default()
1185 },
1186 link: TextStyleRefinement {
1187 background_color: Some(colors.editor_foreground.opacity(0.025)),
1188 underline: Some(UnderlineStyle {
1189 color: Some(colors.text_accent.opacity(0.5)),
1190 thickness: px(1.),
1191 ..Default::default()
1192 }),
1193 ..Default::default()
1194 },
1195 link_callback: Some(Rc::new(move |_url, _cx| {
1196 // todo!()
1197 // if MentionLink::is_valid(url) {
1198 // let colors = cx.theme().colors();
1199 // Some(TextStyleRefinement {
1200 // background_color: Some(colors.element_background),
1201 // ..Default::default()
1202 // })
1203 // } else {
1204 None
1205 // }
1206 })),
1207 ..Default::default()
1208 }
1209}