1use std::path::Path;
2use std::rc::Rc;
3use std::time::Duration;
4
5use agentic_coding_protocol::{self as acp, ToolCallConfirmation};
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, 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 fn sync_thread_entry_view(
236 &mut self,
237 entry_ix: usize,
238 window: &mut Window,
239 cx: &mut Context<Self>,
240 ) {
241 let Some(buffer) = self.entry_diff_buffer(entry_ix, cx) else {
242 return;
243 };
244
245 if let Some(Some(ThreadEntryView::Diff { .. })) = self.thread_entry_views.get(entry_ix) {
246 return;
247 }
248 // todo! should we do this on the fly from render?
249
250 let editor = cx.new(|cx| {
251 let mut editor = Editor::new(
252 EditorMode::Full {
253 scale_ui_elements_with_buffer_font_size: false,
254 show_active_line_background: false,
255 sized_by_content: true,
256 },
257 buffer.clone(),
258 None,
259 window,
260 cx,
261 );
262 editor.set_show_gutter(false, cx);
263 editor.disable_inline_diagnostics();
264 editor.disable_expand_excerpt_buttons(cx);
265 editor.set_show_vertical_scrollbar(false, cx);
266 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
267 editor.set_soft_wrap_mode(SoftWrap::None, cx);
268 editor.scroll_manager.set_forbid_vertical_scroll(true);
269 editor.set_show_indent_guides(false, cx);
270 editor.set_read_only(true);
271 editor.set_show_breakpoints(false, cx);
272 editor.set_show_code_actions(false, cx);
273 editor.set_show_git_diff_gutter(false, cx);
274 editor.set_expand_all_diff_hunks(cx);
275 editor.set_text_style_refinement(TextStyleRefinement {
276 font_size: Some(
277 TextSize::Small
278 .rems(cx)
279 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
280 .into(),
281 ),
282 ..Default::default()
283 });
284 editor
285 });
286
287 if entry_ix >= self.thread_entry_views.len() {
288 self.thread_entry_views
289 .resize_with(entry_ix + 1, Default::default);
290 }
291
292 self.thread_entry_views[entry_ix] = Some(ThreadEntryView::Diff {
293 editor: editor.clone(),
294 });
295 }
296
297 fn entry_diff_buffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
298 let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
299
300 if let AgentThreadEntryContent::ToolCall(ToolCall {
301 status:
302 crate::ToolCallStatus::Allowed {
303 content: Some(ToolCallContent::Diff { buffer, .. }),
304 ..
305 },
306 ..
307 }) = &entry.content
308 {
309 Some(buffer.clone())
310 } else {
311 None
312 }
313 }
314
315 fn authorize_tool_call(
316 &mut self,
317 id: ToolCallId,
318 outcome: acp::ToolCallConfirmationOutcome,
319 cx: &mut Context<Self>,
320 ) {
321 let Some(thread) = self.thread() else {
322 return;
323 };
324 thread.update(cx, |thread, cx| {
325 thread.authorize_tool_call(id, outcome, cx);
326 });
327 cx.notify();
328 }
329
330 fn render_entry(
331 &self,
332 index: usize,
333 entry: &ThreadEntry,
334 window: &mut Window,
335 cx: &Context<Self>,
336 ) -> AnyElement {
337 match &entry.content {
338 AgentThreadEntryContent::Message(message) => {
339 let style = if message.role == Role::User {
340 user_message_markdown_style(window, cx)
341 } else {
342 default_markdown_style(window, cx)
343 };
344 let message_body = div()
345 .children(message.chunks.iter().map(|chunk| match chunk {
346 MessageChunk::Text { chunk } => {
347 // todo!() open link
348 MarkdownElement::new(chunk.clone(), style.clone())
349 }
350 _ => todo!(),
351 }))
352 .into_any();
353
354 match message.role {
355 Role::User => div()
356 .p_2()
357 .pt_5()
358 .child(
359 div()
360 .text_xs()
361 .p_3()
362 .bg(cx.theme().colors().editor_background)
363 .rounded_lg()
364 .shadow_md()
365 .border_1()
366 .border_color(cx.theme().colors().border)
367 .child(message_body),
368 )
369 .into_any(),
370 Role::Assistant => div()
371 .text_ui(cx)
372 .p_5()
373 .pt_2()
374 .child(message_body)
375 .into_any(),
376 }
377 }
378 AgentThreadEntryContent::ToolCall(tool_call) => div()
379 .px_2()
380 .py_4()
381 .child(self.render_tool_call(index, tool_call, window, cx))
382 .into_any(),
383 }
384 }
385
386 fn render_tool_call(
387 &self,
388 entry_ix: usize,
389 tool_call: &ToolCall,
390 window: &Window,
391 cx: &Context<Self>,
392 ) -> Div {
393 let status_icon = match &tool_call.status {
394 ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
395 ToolCallStatus::Allowed {
396 status: acp::ToolCallStatus::Running,
397 ..
398 } => Icon::new(IconName::ArrowCircle)
399 .color(Color::Success)
400 .size(IconSize::Small)
401 .with_animation(
402 "running",
403 Animation::new(Duration::from_secs(2)).repeat(),
404 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
405 )
406 .into_any_element(),
407 ToolCallStatus::Allowed {
408 status: acp::ToolCallStatus::Finished,
409 ..
410 } => Icon::new(IconName::Check)
411 .color(Color::Success)
412 .size(IconSize::Small)
413 .into_any_element(),
414 ToolCallStatus::Rejected
415 | ToolCallStatus::Allowed {
416 status: acp::ToolCallStatus::Error,
417 ..
418 } => Icon::new(IconName::X)
419 .color(Color::Error)
420 .size(IconSize::Small)
421 .into_any_element(),
422 };
423
424 let content = match &tool_call.status {
425 ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
426 Some(self.render_tool_call_confirmation(tool_call.id, confirmation, cx))
427 }
428 ToolCallStatus::Allowed { content, .. } => content.as_ref().map(|content| {
429 div()
430 .border_color(cx.theme().colors().border)
431 .border_t_1()
432 .px_2()
433 .py_1p5()
434 .child(match content {
435 ToolCallContent::Markdown { markdown } => MarkdownElement::new(
436 markdown.clone(),
437 default_markdown_style(window, cx),
438 )
439 .into_any_element(),
440 ToolCallContent::Diff { .. } => {
441 if let Some(Some(ThreadEntryView::Diff { editor })) =
442 self.thread_entry_views.get(entry_ix)
443 {
444 editor.clone().into_any_element()
445 } else {
446 Empty.into_any()
447 }
448 }
449 })
450 .into_any_element()
451 }),
452 ToolCallStatus::Rejected => None,
453 };
454
455 v_flex()
456 .text_xs()
457 .rounded_md()
458 .border_1()
459 .border_color(cx.theme().colors().border)
460 .bg(cx.theme().colors().editor_background)
461 .child(
462 h_flex()
463 .px_2()
464 .py_1p5()
465 .w_full()
466 .gap_1p5()
467 .child(
468 Icon::new(tool_call.icon.into())
469 .size(IconSize::Small)
470 .color(Color::Muted),
471 )
472 // todo! danilo please help
473 .child(MarkdownElement::new(
474 tool_call.label.clone(),
475 default_markdown_style(window, cx),
476 ))
477 .child(div().w_full())
478 .child(status_icon),
479 )
480 .children(content)
481 }
482
483 fn render_tool_call_confirmation(
484 &self,
485 tool_call_id: ToolCallId,
486 confirmation: &ToolCallConfirmation,
487 cx: &Context<Self>,
488 ) -> AnyElement {
489 match confirmation {
490 ToolCallConfirmation::Edit {
491 file_name,
492 file_diff,
493 description,
494 } => v_flex()
495 .border_color(cx.theme().colors().border)
496 .border_t_1()
497 .px_2()
498 .py_1p5()
499 // todo! nicer rendering
500 .child(file_name.clone())
501 .child(file_diff.clone())
502 .children(description.clone())
503 .child(
504 h_flex()
505 .justify_end()
506 .gap_1()
507 .child(
508 Button::new(
509 ("always_allow", tool_call_id.as_u64()),
510 "Always Allow Edits",
511 )
512 .icon(IconName::CheckDouble)
513 .icon_position(IconPosition::Start)
514 .icon_size(IconSize::Small)
515 .icon_color(Color::Success)
516 .on_click(cx.listener({
517 let id = tool_call_id;
518 move |this, _, _, cx| {
519 this.authorize_tool_call(
520 id,
521 acp::ToolCallConfirmationOutcome::AlwaysAllow,
522 cx,
523 );
524 }
525 })),
526 )
527 .child(
528 Button::new(("allow", tool_call_id.as_u64()), "Allow")
529 .icon(IconName::Check)
530 .icon_position(IconPosition::Start)
531 .icon_size(IconSize::Small)
532 .icon_color(Color::Success)
533 .on_click(cx.listener({
534 let id = tool_call_id;
535 move |this, _, _, cx| {
536 this.authorize_tool_call(
537 id,
538 acp::ToolCallConfirmationOutcome::Allow,
539 cx,
540 );
541 }
542 })),
543 )
544 .child(
545 Button::new(("reject", tool_call_id.as_u64()), "Reject")
546 .icon(IconName::X)
547 .icon_position(IconPosition::Start)
548 .icon_size(IconSize::Small)
549 .icon_color(Color::Error)
550 .on_click(cx.listener({
551 let id = tool_call_id;
552 move |this, _, _, cx| {
553 this.authorize_tool_call(
554 id,
555 acp::ToolCallConfirmationOutcome::Reject,
556 cx,
557 );
558 }
559 })),
560 ),
561 )
562 .into_any(),
563 ToolCallConfirmation::Execute {
564 command,
565 root_command,
566 description,
567 } => v_flex()
568 .border_color(cx.theme().colors().border)
569 .border_t_1()
570 .px_2()
571 .py_1p5()
572 // todo! nicer rendering
573 .child(command.clone())
574 .children(description.clone())
575 .child(
576 h_flex()
577 .justify_end()
578 .gap_1()
579 .child(
580 Button::new(
581 ("always_allow", tool_call_id.as_u64()),
582 format!("Always Allow {root_command}"),
583 )
584 .icon(IconName::CheckDouble)
585 .icon_position(IconPosition::Start)
586 .icon_size(IconSize::Small)
587 .icon_color(Color::Success)
588 .on_click(cx.listener({
589 let id = tool_call_id;
590 move |this, _, _, cx| {
591 this.authorize_tool_call(
592 id,
593 acp::ToolCallConfirmationOutcome::AlwaysAllow,
594 cx,
595 );
596 }
597 })),
598 )
599 .child(
600 Button::new(("allow", tool_call_id.as_u64()), "Allow")
601 .icon(IconName::Check)
602 .icon_position(IconPosition::Start)
603 .icon_size(IconSize::Small)
604 .icon_color(Color::Success)
605 .on_click(cx.listener({
606 let id = tool_call_id;
607 move |this, _, _, cx| {
608 this.authorize_tool_call(
609 id,
610 acp::ToolCallConfirmationOutcome::Allow,
611 cx,
612 );
613 }
614 })),
615 )
616 .child(
617 Button::new(("reject", tool_call_id.as_u64()), "Reject")
618 .icon(IconName::X)
619 .icon_position(IconPosition::Start)
620 .icon_size(IconSize::Small)
621 .icon_color(Color::Error)
622 .on_click(cx.listener({
623 let id = tool_call_id;
624 move |this, _, _, cx| {
625 this.authorize_tool_call(
626 id,
627 acp::ToolCallConfirmationOutcome::Reject,
628 cx,
629 );
630 }
631 })),
632 ),
633 )
634 .into_any(),
635 ToolCallConfirmation::Mcp {
636 server_name,
637 tool_name: _,
638 tool_display_name,
639 description,
640 } => v_flex()
641 .border_color(cx.theme().colors().border)
642 .border_t_1()
643 .px_2()
644 .py_1p5()
645 // todo! nicer rendering
646 .child(format!("{server_name} - {tool_display_name}"))
647 .children(description.clone())
648 .child(
649 h_flex()
650 .justify_end()
651 .gap_1()
652 .child(
653 Button::new(
654 ("always_allow_server", tool_call_id.as_u64()),
655 format!("Always Allow {server_name}"),
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::AlwaysAllowMcpServer,
667 cx,
668 );
669 }
670 })),
671 )
672 .child(
673 Button::new(
674 ("always_allow_tool", tool_call_id.as_u64()),
675 format!("Always Allow {tool_display_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::AlwaysAllowTool,
687 cx,
688 );
689 }
690 })),
691 )
692 .child(
693 Button::new(("allow", tool_call_id.as_u64()), "Allow")
694 .icon(IconName::Check)
695 .icon_position(IconPosition::Start)
696 .icon_size(IconSize::Small)
697 .icon_color(Color::Success)
698 .on_click(cx.listener({
699 let id = tool_call_id;
700 move |this, _, _, cx| {
701 this.authorize_tool_call(
702 id,
703 acp::ToolCallConfirmationOutcome::Allow,
704 cx,
705 );
706 }
707 })),
708 )
709 .child(
710 Button::new(("reject", tool_call_id.as_u64()), "Reject")
711 .icon(IconName::X)
712 .icon_position(IconPosition::Start)
713 .icon_size(IconSize::Small)
714 .icon_color(Color::Error)
715 .on_click(cx.listener({
716 let id = tool_call_id;
717 move |this, _, _, cx| {
718 this.authorize_tool_call(
719 id,
720 acp::ToolCallConfirmationOutcome::Reject,
721 cx,
722 );
723 }
724 })),
725 ),
726 )
727 .into_any(),
728 ToolCallConfirmation::Fetch { description, urls } => v_flex()
729 .border_color(cx.theme().colors().border)
730 .border_t_1()
731 .px_2()
732 .py_1p5()
733 // todo! nicer rendering
734 .children(urls.clone())
735 .children(description.clone())
736 .child(
737 h_flex()
738 .justify_end()
739 .gap_1()
740 .child(
741 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
742 .icon(IconName::CheckDouble)
743 .icon_position(IconPosition::Start)
744 .icon_size(IconSize::Small)
745 .icon_color(Color::Success)
746 .on_click(cx.listener({
747 let id = tool_call_id;
748 move |this, _, _, cx| {
749 this.authorize_tool_call(
750 id,
751 acp::ToolCallConfirmationOutcome::AlwaysAllow,
752 cx,
753 );
754 }
755 })),
756 )
757 .child(
758 Button::new(("allow", tool_call_id.as_u64()), "Allow")
759 .icon(IconName::Check)
760 .icon_position(IconPosition::Start)
761 .icon_size(IconSize::Small)
762 .icon_color(Color::Success)
763 .on_click(cx.listener({
764 let id = tool_call_id;
765 move |this, _, _, cx| {
766 this.authorize_tool_call(
767 id,
768 acp::ToolCallConfirmationOutcome::Allow,
769 cx,
770 );
771 }
772 })),
773 )
774 .child(
775 Button::new(("reject", tool_call_id.as_u64()), "Reject")
776 .icon(IconName::X)
777 .icon_position(IconPosition::Start)
778 .icon_size(IconSize::Small)
779 .icon_color(Color::Error)
780 .on_click(cx.listener({
781 let id = tool_call_id;
782 move |this, _, _, cx| {
783 this.authorize_tool_call(
784 id,
785 acp::ToolCallConfirmationOutcome::Reject,
786 cx,
787 );
788 }
789 })),
790 ),
791 )
792 .into_any(),
793 ToolCallConfirmation::Other { description } => v_flex()
794 .border_color(cx.theme().colors().border)
795 .border_t_1()
796 .px_2()
797 .py_1p5()
798 // todo! nicer rendering
799 .child(description.clone())
800 .child(
801 h_flex()
802 .justify_end()
803 .gap_1()
804 .child(
805 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
806 .icon(IconName::CheckDouble)
807 .icon_position(IconPosition::Start)
808 .icon_size(IconSize::Small)
809 .icon_color(Color::Success)
810 .on_click(cx.listener({
811 let id = tool_call_id;
812 move |this, _, _, cx| {
813 this.authorize_tool_call(
814 id,
815 acp::ToolCallConfirmationOutcome::AlwaysAllow,
816 cx,
817 );
818 }
819 })),
820 )
821 .child(
822 Button::new(("allow", tool_call_id.as_u64()), "Allow")
823 .icon(IconName::Check)
824 .icon_position(IconPosition::Start)
825 .icon_size(IconSize::Small)
826 .icon_color(Color::Success)
827 .on_click(cx.listener({
828 let id = tool_call_id;
829 move |this, _, _, cx| {
830 this.authorize_tool_call(
831 id,
832 acp::ToolCallConfirmationOutcome::Allow,
833 cx,
834 );
835 }
836 })),
837 )
838 .child(
839 Button::new(("reject", tool_call_id.as_u64()), "Reject")
840 .icon(IconName::X)
841 .icon_position(IconPosition::Start)
842 .icon_size(IconSize::Small)
843 .icon_color(Color::Error)
844 .on_click(cx.listener({
845 let id = tool_call_id;
846 move |this, _, _, cx| {
847 this.authorize_tool_call(
848 id,
849 acp::ToolCallConfirmationOutcome::Reject,
850 cx,
851 );
852 }
853 })),
854 ),
855 )
856 .into_any(),
857 }
858 }
859}
860
861impl Focusable for AcpThreadView {
862 fn focus_handle(&self, cx: &App) -> FocusHandle {
863 self.message_editor.focus_handle(cx)
864 }
865}
866
867impl Render for AcpThreadView {
868 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
869 let text = self.message_editor.read(cx).text(cx);
870 let is_editor_empty = text.is_empty();
871 let focus_handle = self.message_editor.focus_handle(cx);
872
873 v_flex()
874 .key_context("MessageEditor")
875 .on_action(cx.listener(Self::chat))
876 .h_full()
877 .child(match &self.thread_state {
878 ThreadState::Loading { .. } => v_flex()
879 .p_2()
880 .flex_1()
881 .justify_end()
882 .child(Label::new("Connecting to Gemini...")),
883 ThreadState::LoadError(e) => div()
884 .p_2()
885 .flex_1()
886 .justify_end()
887 .child(Label::new(format!("Failed to load: {e}")).into_any_element()),
888 ThreadState::Ready { thread, .. } => v_flex()
889 .flex_1()
890 .gap_2()
891 .pb_2()
892 .child(
893 list(self.list_state.clone())
894 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
895 .flex_grow(),
896 )
897 .child(div().px_3().children(if self.send_task.is_none() {
898 None
899 } else {
900 Label::new(if thread.read(cx).waiting_for_tool_confirmation() {
901 "Waiting for tool confirmation"
902 } else {
903 "Generating..."
904 })
905 .color(Color::Muted)
906 .size(LabelSize::Small)
907 .into()
908 })),
909 })
910 .child(
911 v_flex()
912 .bg(cx.theme().colors().editor_background)
913 .border_t_1()
914 .border_color(cx.theme().colors().border)
915 .p_2()
916 .gap_2()
917 .child(self.message_editor.clone())
918 .child(h_flex().justify_end().child(if self.send_task.is_some() {
919 IconButton::new("stop-generation", IconName::StopFilled)
920 .icon_color(Color::Error)
921 .style(ButtonStyle::Tinted(ui::TintColor::Error))
922 .tooltip(move |window, cx| {
923 Tooltip::for_action(
924 "Stop Generation",
925 &editor::actions::Cancel,
926 window,
927 cx,
928 )
929 })
930 .disabled(is_editor_empty)
931 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
932 } else {
933 IconButton::new("send-message", IconName::Send)
934 .icon_color(Color::Accent)
935 .style(ButtonStyle::Filled)
936 .disabled(is_editor_empty)
937 .on_click({
938 let focus_handle = focus_handle.clone();
939 move |_event, window, cx| {
940 focus_handle.dispatch_action(&Chat, window, cx);
941 }
942 })
943 .when(!is_editor_empty, |button| {
944 button.tooltip(move |window, cx| {
945 Tooltip::for_action("Send", &Chat, window, cx)
946 })
947 })
948 .when(is_editor_empty, |button| {
949 button.tooltip(Tooltip::text("Type a message to submit"))
950 })
951 })),
952 )
953 }
954}
955
956fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
957 let mut style = default_markdown_style(window, cx);
958 let mut text_style = window.text_style();
959 let theme_settings = ThemeSettings::get_global(cx);
960
961 let buffer_font = theme_settings.buffer_font.family.clone();
962 let buffer_font_size = TextSize::Small.rems(cx);
963
964 text_style.refine(&TextStyleRefinement {
965 font_family: Some(buffer_font),
966 font_size: Some(buffer_font_size.into()),
967 ..Default::default()
968 });
969
970 style.base_text_style = text_style;
971 style
972}
973
974fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
975 let theme_settings = ThemeSettings::get_global(cx);
976 let colors = cx.theme().colors();
977 let ui_font_size = TextSize::Default.rems(cx);
978 let buffer_font_size = TextSize::Small.rems(cx);
979 let mut text_style = window.text_style();
980 let line_height = buffer_font_size * 1.75;
981
982 text_style.refine(&TextStyleRefinement {
983 font_family: Some(theme_settings.ui_font.family.clone()),
984 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
985 font_features: Some(theme_settings.ui_font.features.clone()),
986 font_size: Some(ui_font_size.into()),
987 line_height: Some(line_height.into()),
988 color: Some(cx.theme().colors().text),
989 ..Default::default()
990 });
991
992 MarkdownStyle {
993 base_text_style: text_style.clone(),
994 syntax: cx.theme().syntax().clone(),
995 selection_background_color: cx.theme().colors().element_selection_background,
996 code_block_overflow_x_scroll: true,
997 table_overflow_x_scroll: true,
998 heading_level_styles: Some(HeadingLevelStyles {
999 h1: Some(TextStyleRefinement {
1000 font_size: Some(rems(1.15).into()),
1001 ..Default::default()
1002 }),
1003 h2: Some(TextStyleRefinement {
1004 font_size: Some(rems(1.1).into()),
1005 ..Default::default()
1006 }),
1007 h3: Some(TextStyleRefinement {
1008 font_size: Some(rems(1.05).into()),
1009 ..Default::default()
1010 }),
1011 h4: Some(TextStyleRefinement {
1012 font_size: Some(rems(1.).into()),
1013 ..Default::default()
1014 }),
1015 h5: Some(TextStyleRefinement {
1016 font_size: Some(rems(0.95).into()),
1017 ..Default::default()
1018 }),
1019 h6: Some(TextStyleRefinement {
1020 font_size: Some(rems(0.875).into()),
1021 ..Default::default()
1022 }),
1023 }),
1024 code_block: StyleRefinement {
1025 padding: EdgesRefinement {
1026 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1027 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1028 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1029 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1030 },
1031 background: Some(colors.editor_background.into()),
1032 text: Some(TextStyleRefinement {
1033 font_family: Some(theme_settings.buffer_font.family.clone()),
1034 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1035 font_features: Some(theme_settings.buffer_font.features.clone()),
1036 font_size: Some(buffer_font_size.into()),
1037 ..Default::default()
1038 }),
1039 ..Default::default()
1040 },
1041 inline_code: TextStyleRefinement {
1042 font_family: Some(theme_settings.buffer_font.family.clone()),
1043 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1044 font_features: Some(theme_settings.buffer_font.features.clone()),
1045 font_size: Some(buffer_font_size.into()),
1046 background_color: Some(colors.editor_foreground.opacity(0.08)),
1047 ..Default::default()
1048 },
1049 link: TextStyleRefinement {
1050 background_color: Some(colors.editor_foreground.opacity(0.025)),
1051 underline: Some(UnderlineStyle {
1052 color: Some(colors.text_accent.opacity(0.5)),
1053 thickness: px(1.),
1054 ..Default::default()
1055 }),
1056 ..Default::default()
1057 },
1058 link_callback: Some(Rc::new(move |_url, _cx| {
1059 // todo!()
1060 // if MentionLink::is_valid(url) {
1061 // let colors = cx.theme().colors();
1062 // Some(TextStyleRefinement {
1063 // background_color: Some(colors.element_background),
1064 // ..Default::default()
1065 // })
1066 // } else {
1067 None
1068 // }
1069 })),
1070 ..Default::default()
1071 }
1072}