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