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 dbg!(&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::Message(message) => {
395 let style = if message.role == Role::User {
396 user_message_markdown_style(window, cx)
397 } else {
398 default_markdown_style(window, cx)
399 };
400 let message_body = div()
401 .children(message.chunks.iter().map(|chunk| match chunk {
402 MessageChunk::Text { chunk } => {
403 // todo!() open link
404 MarkdownElement::new(chunk.clone(), style.clone())
405 }
406 _ => todo!(),
407 }))
408 .into_any();
409
410 match message.role {
411 Role::User => div()
412 .p_2()
413 .pt_5()
414 .child(
415 div()
416 .text_xs()
417 .p_3()
418 .bg(cx.theme().colors().editor_background)
419 .rounded_lg()
420 .shadow_md()
421 .border_1()
422 .border_color(cx.theme().colors().border)
423 .child(message_body),
424 )
425 .into_any(),
426 Role::Assistant => div()
427 .text_ui(cx)
428 .p_5()
429 .pt_2()
430 .child(message_body)
431 .into_any(),
432 }
433 }
434 AgentThreadEntryContent::ToolCall(tool_call) => div()
435 .px_2()
436 .py_4()
437 .child(self.render_tool_call(index, tool_call, window, cx))
438 .into_any(),
439 }
440 }
441
442 fn render_tool_call(
443 &self,
444 entry_ix: usize,
445 tool_call: &ToolCall,
446 window: &Window,
447 cx: &Context<Self>,
448 ) -> Div {
449 let status_icon = match &tool_call.status {
450 ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
451 ToolCallStatus::Allowed {
452 status: acp::ToolCallStatus::Running,
453 ..
454 } => Icon::new(IconName::ArrowCircle)
455 .color(Color::Success)
456 .size(IconSize::Small)
457 .with_animation(
458 "running",
459 Animation::new(Duration::from_secs(2)).repeat(),
460 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
461 )
462 .into_any_element(),
463 ToolCallStatus::Allowed {
464 status: acp::ToolCallStatus::Finished,
465 ..
466 } => Icon::new(IconName::Check)
467 .color(Color::Success)
468 .size(IconSize::Small)
469 .into_any_element(),
470 ToolCallStatus::Rejected
471 | ToolCallStatus::Allowed {
472 status: acp::ToolCallStatus::Error,
473 ..
474 } => Icon::new(IconName::X)
475 .color(Color::Error)
476 .size(IconSize::Small)
477 .into_any_element(),
478 };
479
480 let content = match &tool_call.status {
481 ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
482 Some(self.render_tool_call_confirmation(
483 entry_ix,
484 tool_call.id,
485 confirmation,
486 tool_call.content.as_ref(),
487 window,
488 cx,
489 ))
490 }
491 ToolCallStatus::Allowed { .. } => tool_call.content.as_ref().map(|content| {
492 div()
493 .border_color(cx.theme().colors().border)
494 .border_t_1()
495 .px_2()
496 .py_1p5()
497 .child(self.render_tool_call_content(entry_ix, content, window, cx))
498 .into_any_element()
499 }),
500 ToolCallStatus::Rejected => None,
501 };
502
503 v_flex()
504 .text_xs()
505 .rounded_md()
506 .border_1()
507 .border_color(cx.theme().colors().border)
508 .bg(cx.theme().colors().editor_background)
509 .child(
510 h_flex()
511 .px_2()
512 .py_1p5()
513 .w_full()
514 .gap_1p5()
515 .child(
516 Icon::new(tool_call.icon)
517 .size(IconSize::Small)
518 .color(Color::Muted),
519 )
520 // todo! danilo please help
521 .child(MarkdownElement::new(
522 tool_call.label.clone(),
523 default_markdown_style(window, cx),
524 ))
525 .child(div().w_full())
526 .child(status_icon),
527 )
528 .children(content)
529 }
530
531 fn render_tool_call_content(
532 &self,
533 entry_ix: usize,
534 content: &ToolCallContent,
535 window: &Window,
536 cx: &Context<Self>,
537 ) -> AnyElement {
538 match content {
539 ToolCallContent::Markdown { markdown } => {
540 MarkdownElement::new(markdown.clone(), default_markdown_style(window, cx))
541 .into_any_element()
542 }
543 ToolCallContent::Diff { .. } => self.render_diff_editor(entry_ix),
544 }
545 }
546
547 fn render_tool_call_confirmation(
548 &self,
549 entry_ix: usize,
550 tool_call_id: ToolCallId,
551 confirmation: &ToolCallConfirmation,
552 content: Option<&ToolCallContent>,
553 window: &Window,
554 cx: &Context<Self>,
555 ) -> AnyElement {
556 match confirmation {
557 ToolCallConfirmation::Edit { description } => {
558 v_flex()
559 .border_color(cx.theme().colors().border)
560 .border_t_1()
561 .px_2()
562 .py_1p5()
563 .children(description.clone().map(|description| {
564 MarkdownElement::new(description, default_markdown_style(window, cx))
565 }))
566 .children(content.map(|content| {
567 self.render_tool_call_content(entry_ix, content, window, cx)
568 }))
569 .child(
570 h_flex()
571 .justify_end()
572 .gap_1()
573 .child(
574 Button::new(
575 ("always_allow", tool_call_id.as_u64()),
576 "Always Allow Edits",
577 )
578 .icon(IconName::CheckDouble)
579 .icon_position(IconPosition::Start)
580 .icon_size(IconSize::Small)
581 .icon_color(Color::Success)
582 .on_click(cx.listener({
583 let id = tool_call_id;
584 move |this, _, _, cx| {
585 this.authorize_tool_call(
586 id,
587 acp::ToolCallConfirmationOutcome::AlwaysAllow,
588 cx,
589 );
590 }
591 })),
592 )
593 .child(
594 Button::new(("allow", tool_call_id.as_u64()), "Allow")
595 .icon(IconName::Check)
596 .icon_position(IconPosition::Start)
597 .icon_size(IconSize::Small)
598 .icon_color(Color::Success)
599 .on_click(cx.listener({
600 let id = tool_call_id;
601 move |this, _, _, cx| {
602 this.authorize_tool_call(
603 id,
604 acp::ToolCallConfirmationOutcome::Allow,
605 cx,
606 );
607 }
608 })),
609 )
610 .child(
611 Button::new(("reject", tool_call_id.as_u64()), "Reject")
612 .icon(IconName::X)
613 .icon_position(IconPosition::Start)
614 .icon_size(IconSize::Small)
615 .icon_color(Color::Error)
616 .on_click(cx.listener({
617 let id = tool_call_id;
618 move |this, _, _, cx| {
619 this.authorize_tool_call(
620 id,
621 acp::ToolCallConfirmationOutcome::Reject,
622 cx,
623 );
624 }
625 })),
626 ),
627 )
628 .into_any()
629 }
630 ToolCallConfirmation::Execute {
631 command,
632 root_command,
633 description,
634 } => {
635 v_flex()
636 .border_color(cx.theme().colors().border)
637 .border_t_1()
638 .px_2()
639 .py_1p5()
640 // todo! nicer rendering
641 .child(command.clone())
642 .children(description.clone().map(|description| {
643 MarkdownElement::new(description, default_markdown_style(window, cx))
644 }))
645 .children(content.map(|content| {
646 self.render_tool_call_content(entry_ix, content, window, cx)
647 }))
648 .child(
649 h_flex()
650 .justify_end()
651 .gap_1()
652 .child(
653 Button::new(
654 ("always_allow", tool_call_id.as_u64()),
655 format!("Always Allow {root_command}"),
656 )
657 .icon(IconName::CheckDouble)
658 .icon_position(IconPosition::Start)
659 .icon_size(IconSize::Small)
660 .icon_color(Color::Success)
661 .on_click(cx.listener({
662 let id = tool_call_id;
663 move |this, _, _, cx| {
664 this.authorize_tool_call(
665 id,
666 acp::ToolCallConfirmationOutcome::AlwaysAllow,
667 cx,
668 );
669 }
670 })),
671 )
672 .child(
673 Button::new(("allow", tool_call_id.as_u64()), "Allow")
674 .icon(IconName::Check)
675 .icon_position(IconPosition::Start)
676 .icon_size(IconSize::Small)
677 .icon_color(Color::Success)
678 .on_click(cx.listener({
679 let id = tool_call_id;
680 move |this, _, _, cx| {
681 this.authorize_tool_call(
682 id,
683 acp::ToolCallConfirmationOutcome::Allow,
684 cx,
685 );
686 }
687 })),
688 )
689 .child(
690 Button::new(("reject", tool_call_id.as_u64()), "Reject")
691 .icon(IconName::X)
692 .icon_position(IconPosition::Start)
693 .icon_size(IconSize::Small)
694 .icon_color(Color::Error)
695 .on_click(cx.listener({
696 let id = tool_call_id;
697 move |this, _, _, cx| {
698 this.authorize_tool_call(
699 id,
700 acp::ToolCallConfirmationOutcome::Reject,
701 cx,
702 );
703 }
704 })),
705 ),
706 )
707 .into_any()
708 }
709 ToolCallConfirmation::Mcp {
710 server_name,
711 tool_name: _,
712 tool_display_name,
713 description,
714 } => {
715 v_flex()
716 .border_color(cx.theme().colors().border)
717 .border_t_1()
718 .px_2()
719 .py_1p5()
720 // todo! nicer rendering
721 .child(format!("{server_name} - {tool_display_name}"))
722 .children(description.clone().map(|description| {
723 MarkdownElement::new(description, default_markdown_style(window, cx))
724 }))
725 .children(content.map(|content| {
726 self.render_tool_call_content(entry_ix, content, window, cx)
727 }))
728 .child(
729 h_flex()
730 .justify_end()
731 .gap_1()
732 .child(
733 Button::new(
734 ("always_allow_server", tool_call_id.as_u64()),
735 format!("Always Allow {server_name}"),
736 )
737 .icon(IconName::CheckDouble)
738 .icon_position(IconPosition::Start)
739 .icon_size(IconSize::Small)
740 .icon_color(Color::Success)
741 .on_click(cx.listener({
742 let id = tool_call_id;
743 move |this, _, _, cx| {
744 this.authorize_tool_call(
745 id,
746 acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
747 cx,
748 );
749 }
750 })),
751 )
752 .child(
753 Button::new(
754 ("always_allow_tool", tool_call_id.as_u64()),
755 format!("Always Allow {tool_display_name}"),
756 )
757 .icon(IconName::CheckDouble)
758 .icon_position(IconPosition::Start)
759 .icon_size(IconSize::Small)
760 .icon_color(Color::Success)
761 .on_click(cx.listener({
762 let id = tool_call_id;
763 move |this, _, _, cx| {
764 this.authorize_tool_call(
765 id,
766 acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
767 cx,
768 );
769 }
770 })),
771 )
772 .child(
773 Button::new(("allow", tool_call_id.as_u64()), "Allow")
774 .icon(IconName::Check)
775 .icon_position(IconPosition::Start)
776 .icon_size(IconSize::Small)
777 .icon_color(Color::Success)
778 .on_click(cx.listener({
779 let id = tool_call_id;
780 move |this, _, _, cx| {
781 this.authorize_tool_call(
782 id,
783 acp::ToolCallConfirmationOutcome::Allow,
784 cx,
785 );
786 }
787 })),
788 )
789 .child(
790 Button::new(("reject", tool_call_id.as_u64()), "Reject")
791 .icon(IconName::X)
792 .icon_position(IconPosition::Start)
793 .icon_size(IconSize::Small)
794 .icon_color(Color::Error)
795 .on_click(cx.listener({
796 let id = tool_call_id;
797 move |this, _, _, cx| {
798 this.authorize_tool_call(
799 id,
800 acp::ToolCallConfirmationOutcome::Reject,
801 cx,
802 );
803 }
804 })),
805 ),
806 )
807 .into_any()
808 }
809 ToolCallConfirmation::Fetch { description, urls } => v_flex()
810 .border_color(cx.theme().colors().border)
811 .border_t_1()
812 .px_2()
813 .py_1p5()
814 // todo! nicer rendering
815 .children(urls.clone())
816 .children(description.clone().map(|description| {
817 MarkdownElement::new(description, default_markdown_style(window, cx))
818 }))
819 .children(
820 content.map(|content| {
821 self.render_tool_call_content(entry_ix, content, window, cx)
822 }),
823 )
824 .child(
825 h_flex()
826 .justify_end()
827 .gap_1()
828 .child(
829 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
830 .icon(IconName::CheckDouble)
831 .icon_position(IconPosition::Start)
832 .icon_size(IconSize::Small)
833 .icon_color(Color::Success)
834 .on_click(cx.listener({
835 let id = tool_call_id;
836 move |this, _, _, cx| {
837 this.authorize_tool_call(
838 id,
839 acp::ToolCallConfirmationOutcome::AlwaysAllow,
840 cx,
841 );
842 }
843 })),
844 )
845 .child(
846 Button::new(("allow", tool_call_id.as_u64()), "Allow")
847 .icon(IconName::Check)
848 .icon_position(IconPosition::Start)
849 .icon_size(IconSize::Small)
850 .icon_color(Color::Success)
851 .on_click(cx.listener({
852 let id = tool_call_id;
853 move |this, _, _, cx| {
854 this.authorize_tool_call(
855 id,
856 acp::ToolCallConfirmationOutcome::Allow,
857 cx,
858 );
859 }
860 })),
861 )
862 .child(
863 Button::new(("reject", tool_call_id.as_u64()), "Reject")
864 .icon(IconName::X)
865 .icon_position(IconPosition::Start)
866 .icon_size(IconSize::Small)
867 .icon_color(Color::Error)
868 .on_click(cx.listener({
869 let id = tool_call_id;
870 move |this, _, _, cx| {
871 this.authorize_tool_call(
872 id,
873 acp::ToolCallConfirmationOutcome::Reject,
874 cx,
875 );
876 }
877 })),
878 ),
879 )
880 .into_any(),
881 ToolCallConfirmation::Other { description } => v_flex()
882 .border_color(cx.theme().colors().border)
883 .border_t_1()
884 .px_2()
885 .py_1p5()
886 // todo! nicer rendering
887 .child(MarkdownElement::new(
888 description.clone(),
889 default_markdown_style(window, cx),
890 ))
891 .children(
892 content.map(|content| {
893 self.render_tool_call_content(entry_ix, content, window, cx)
894 }),
895 )
896 .child(
897 h_flex()
898 .justify_end()
899 .gap_1()
900 .child(
901 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
902 .icon(IconName::CheckDouble)
903 .icon_position(IconPosition::Start)
904 .icon_size(IconSize::Small)
905 .icon_color(Color::Success)
906 .on_click(cx.listener({
907 let id = tool_call_id;
908 move |this, _, _, cx| {
909 this.authorize_tool_call(
910 id,
911 acp::ToolCallConfirmationOutcome::AlwaysAllow,
912 cx,
913 );
914 }
915 })),
916 )
917 .child(
918 Button::new(("allow", tool_call_id.as_u64()), "Allow")
919 .icon(IconName::Check)
920 .icon_position(IconPosition::Start)
921 .icon_size(IconSize::Small)
922 .icon_color(Color::Success)
923 .on_click(cx.listener({
924 let id = tool_call_id;
925 move |this, _, _, cx| {
926 this.authorize_tool_call(
927 id,
928 acp::ToolCallConfirmationOutcome::Allow,
929 cx,
930 );
931 }
932 })),
933 )
934 .child(
935 Button::new(("reject", tool_call_id.as_u64()), "Reject")
936 .icon(IconName::X)
937 .icon_position(IconPosition::Start)
938 .icon_size(IconSize::Small)
939 .icon_color(Color::Error)
940 .on_click(cx.listener({
941 let id = tool_call_id;
942 move |this, _, _, cx| {
943 this.authorize_tool_call(
944 id,
945 acp::ToolCallConfirmationOutcome::Reject,
946 cx,
947 );
948 }
949 })),
950 ),
951 )
952 .into_any(),
953 }
954 }
955
956 fn render_diff_editor(&self, entry_ix: usize) -> AnyElement {
957 if let Some(Some(ThreadEntryView::Diff { editor })) = self.thread_entry_views.get(entry_ix)
958 {
959 editor.clone().into_any_element()
960 } else {
961 Empty.into_any()
962 }
963 }
964}
965
966impl Focusable for AcpThreadView {
967 fn focus_handle(&self, cx: &App) -> FocusHandle {
968 self.message_editor.focus_handle(cx)
969 }
970}
971
972impl Render for AcpThreadView {
973 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
974 let text = self.message_editor.read(cx).text(cx);
975 let is_editor_empty = text.is_empty();
976 let focus_handle = self.message_editor.focus_handle(cx);
977
978 v_flex()
979 .key_context("MessageEditor")
980 .on_action(cx.listener(Self::chat))
981 .h_full()
982 .child(match &self.thread_state {
983 ThreadState::Unauthenticated => v_flex()
984 .p_2()
985 .flex_1()
986 .justify_end()
987 .child(Label::new("Not authenticated"))
988 .child(Button::new("sign-in", "Sign in via Gemini CLI").on_click(
989 cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
990 )),
991 ThreadState::Loading { .. } => v_flex()
992 .p_2()
993 .flex_1()
994 .justify_end()
995 .child(Label::new("Connecting to Gemini...")),
996 ThreadState::LoadError(e) => div()
997 .p_2()
998 .flex_1()
999 .justify_end()
1000 .child(Label::new(format!("Failed to load: {e}")).into_any_element()),
1001 ThreadState::Ready { thread, .. } => v_flex()
1002 .flex_1()
1003 .gap_2()
1004 .pb_2()
1005 .child(
1006 list(self.list_state.clone())
1007 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
1008 .flex_grow(),
1009 )
1010 .child(div().px_3().children(if self.send_task.is_none() {
1011 None
1012 } else {
1013 Label::new(if thread.read(cx).waiting_for_tool_confirmation() {
1014 "Waiting for tool confirmation"
1015 } else {
1016 "Generating..."
1017 })
1018 .color(Color::Muted)
1019 .size(LabelSize::Small)
1020 .into()
1021 })),
1022 })
1023 .when_some(self.last_error.clone(), |el, error| {
1024 el.child(
1025 div()
1026 .text_xs()
1027 .p_2()
1028 .gap_2()
1029 .border_t_1()
1030 .border_color(cx.theme().status().error_border)
1031 .bg(cx.theme().status().error_background)
1032 .child(MarkdownElement::new(
1033 error,
1034 default_markdown_style(window, cx),
1035 )),
1036 )
1037 })
1038 .child(
1039 v_flex()
1040 .bg(cx.theme().colors().editor_background)
1041 .border_t_1()
1042 .border_color(cx.theme().colors().border)
1043 .p_2()
1044 .gap_2()
1045 .child(self.message_editor.clone())
1046 .child(h_flex().justify_end().child(if self.send_task.is_some() {
1047 IconButton::new("stop-generation", IconName::StopFilled)
1048 .icon_color(Color::Error)
1049 .style(ButtonStyle::Tinted(ui::TintColor::Error))
1050 .tooltip(move |window, cx| {
1051 Tooltip::for_action(
1052 "Stop Generation",
1053 &editor::actions::Cancel,
1054 window,
1055 cx,
1056 )
1057 })
1058 .disabled(is_editor_empty)
1059 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
1060 } else {
1061 IconButton::new("send-message", IconName::Send)
1062 .icon_color(Color::Accent)
1063 .style(ButtonStyle::Filled)
1064 .disabled(is_editor_empty)
1065 .on_click({
1066 let focus_handle = focus_handle.clone();
1067 move |_event, window, cx| {
1068 focus_handle.dispatch_action(&Chat, window, cx);
1069 }
1070 })
1071 .when(!is_editor_empty, |button| {
1072 button.tooltip(move |window, cx| {
1073 Tooltip::for_action("Send", &Chat, window, cx)
1074 })
1075 })
1076 .when(is_editor_empty, |button| {
1077 button.tooltip(Tooltip::text("Type a message to submit"))
1078 })
1079 })),
1080 )
1081 }
1082}
1083
1084fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1085 let mut style = default_markdown_style(window, cx);
1086 let mut text_style = window.text_style();
1087 let theme_settings = ThemeSettings::get_global(cx);
1088
1089 let buffer_font = theme_settings.buffer_font.family.clone();
1090 let buffer_font_size = TextSize::Small.rems(cx);
1091
1092 text_style.refine(&TextStyleRefinement {
1093 font_family: Some(buffer_font),
1094 font_size: Some(buffer_font_size.into()),
1095 ..Default::default()
1096 });
1097
1098 style.base_text_style = text_style;
1099 style
1100}
1101
1102fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1103 let theme_settings = ThemeSettings::get_global(cx);
1104 let colors = cx.theme().colors();
1105 let ui_font_size = TextSize::Default.rems(cx);
1106 let buffer_font_size = TextSize::Small.rems(cx);
1107 let mut text_style = window.text_style();
1108 let line_height = buffer_font_size * 1.75;
1109
1110 text_style.refine(&TextStyleRefinement {
1111 font_family: Some(theme_settings.ui_font.family.clone()),
1112 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1113 font_features: Some(theme_settings.ui_font.features.clone()),
1114 font_size: Some(ui_font_size.into()),
1115 line_height: Some(line_height.into()),
1116 color: Some(cx.theme().colors().text),
1117 ..Default::default()
1118 });
1119
1120 MarkdownStyle {
1121 base_text_style: text_style.clone(),
1122 syntax: cx.theme().syntax().clone(),
1123 selection_background_color: cx.theme().colors().element_selection_background,
1124 code_block_overflow_x_scroll: true,
1125 table_overflow_x_scroll: true,
1126 heading_level_styles: Some(HeadingLevelStyles {
1127 h1: Some(TextStyleRefinement {
1128 font_size: Some(rems(1.15).into()),
1129 ..Default::default()
1130 }),
1131 h2: Some(TextStyleRefinement {
1132 font_size: Some(rems(1.1).into()),
1133 ..Default::default()
1134 }),
1135 h3: Some(TextStyleRefinement {
1136 font_size: Some(rems(1.05).into()),
1137 ..Default::default()
1138 }),
1139 h4: Some(TextStyleRefinement {
1140 font_size: Some(rems(1.).into()),
1141 ..Default::default()
1142 }),
1143 h5: Some(TextStyleRefinement {
1144 font_size: Some(rems(0.95).into()),
1145 ..Default::default()
1146 }),
1147 h6: Some(TextStyleRefinement {
1148 font_size: Some(rems(0.875).into()),
1149 ..Default::default()
1150 }),
1151 }),
1152 code_block: StyleRefinement {
1153 padding: EdgesRefinement {
1154 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1155 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1156 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1157 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1158 },
1159 background: Some(colors.editor_background.into()),
1160 text: Some(TextStyleRefinement {
1161 font_family: Some(theme_settings.buffer_font.family.clone()),
1162 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1163 font_features: Some(theme_settings.buffer_font.features.clone()),
1164 font_size: Some(buffer_font_size.into()),
1165 ..Default::default()
1166 }),
1167 ..Default::default()
1168 },
1169 inline_code: 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 background_color: Some(colors.editor_foreground.opacity(0.08)),
1175 ..Default::default()
1176 },
1177 link: TextStyleRefinement {
1178 background_color: Some(colors.editor_foreground.opacity(0.025)),
1179 underline: Some(UnderlineStyle {
1180 color: Some(colors.text_accent.opacity(0.5)),
1181 thickness: px(1.),
1182 ..Default::default()
1183 }),
1184 ..Default::default()
1185 },
1186 link_callback: Some(Rc::new(move |_url, _cx| {
1187 // todo!()
1188 // if MentionLink::is_valid(url) {
1189 // let colors = cx.theme().colors();
1190 // Some(TextStyleRefinement {
1191 // background_color: Some(colors.element_background),
1192 // ..Default::default()
1193 // })
1194 // } else {
1195 None
1196 // }
1197 })),
1198 ..Default::default()
1199 }
1200}