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