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