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