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