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
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_1p5()
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(div().relative().rounded_b_lg().mt_2().pl_4().child(
471 div().max_h_20().text_ui_sm(cx).overflow_hidden().child(
472 // todo! url click
473 MarkdownElement::new(chunk, default_markdown_style(window, cx)),
474 // .on_url_click({
475 // let workspace = self.workspace.clone();
476 // move |text, window, cx| {
477 // open_markdown_link(text, workspace.clone(), window, cx);
478 // }
479 // }),
480 ),
481 ))
482 .into_any_element()
483 }
484
485 fn render_tool_call(
486 &self,
487 entry_ix: usize,
488 tool_call: &ToolCall,
489 window: &Window,
490 cx: &Context<Self>,
491 ) -> Div {
492 let status_icon = match &tool_call.status {
493 ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
494 ToolCallStatus::Allowed {
495 status: acp::ToolCallStatus::Running,
496 ..
497 } => Icon::new(IconName::ArrowCircle)
498 .color(Color::Success)
499 .size(IconSize::Small)
500 .with_animation(
501 "running",
502 Animation::new(Duration::from_secs(2)).repeat(),
503 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
504 )
505 .into_any_element(),
506 ToolCallStatus::Allowed {
507 status: acp::ToolCallStatus::Finished,
508 ..
509 } => Icon::new(IconName::Check)
510 .color(Color::Success)
511 .size(IconSize::Small)
512 .into_any_element(),
513 ToolCallStatus::Rejected
514 | ToolCallStatus::Canceled
515 | ToolCallStatus::Allowed {
516 status: acp::ToolCallStatus::Error,
517 ..
518 } => Icon::new(IconName::X)
519 .color(Color::Error)
520 .size(IconSize::Small)
521 .into_any_element(),
522 };
523
524 let content = match &tool_call.status {
525 ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
526 Some(self.render_tool_call_confirmation(
527 entry_ix,
528 tool_call.id,
529 confirmation,
530 tool_call.content.as_ref(),
531 window,
532 cx,
533 ))
534 }
535 ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
536 tool_call.content.as_ref().map(|content| {
537 div()
538 .border_color(cx.theme().colors().border)
539 .border_t_1()
540 .px_2()
541 .py_1p5()
542 .child(self.render_tool_call_content(entry_ix, content, window, cx))
543 .into_any_element()
544 })
545 }
546 ToolCallStatus::Rejected => None,
547 };
548
549 v_flex()
550 .text_xs()
551 .rounded_md()
552 .border_1()
553 .border_color(cx.theme().colors().border)
554 .bg(cx.theme().colors().editor_background)
555 .child(
556 h_flex()
557 .px_2()
558 .py_1p5()
559 .w_full()
560 .gap_1p5()
561 .child(
562 Icon::new(tool_call.icon)
563 .size(IconSize::Small)
564 .color(Color::Muted),
565 )
566 // todo! danilo please help
567 .child(MarkdownElement::new(
568 tool_call.label.clone(),
569 default_markdown_style(window, cx),
570 ))
571 .child(div().w_full())
572 .child(status_icon),
573 )
574 .children(content)
575 }
576
577 fn render_tool_call_content(
578 &self,
579 entry_ix: usize,
580 content: &ToolCallContent,
581 window: &Window,
582 cx: &Context<Self>,
583 ) -> AnyElement {
584 match content {
585 ToolCallContent::Markdown { markdown } => {
586 MarkdownElement::new(markdown.clone(), default_markdown_style(window, cx))
587 .into_any_element()
588 }
589 ToolCallContent::Diff {
590 diff: Diff { path, .. },
591 ..
592 } => self.render_diff_editor(entry_ix, path),
593 }
594 }
595
596 fn render_tool_call_confirmation(
597 &self,
598 entry_ix: usize,
599 tool_call_id: ToolCallId,
600 confirmation: &ToolCallConfirmation,
601 content: Option<&ToolCallContent>,
602 window: &Window,
603 cx: &Context<Self>,
604 ) -> AnyElement {
605 match confirmation {
606 ToolCallConfirmation::Edit { description } => {
607 v_flex()
608 .border_color(cx.theme().colors().border)
609 .border_t_1()
610 .px_2()
611 .py_1p5()
612 .children(description.clone().map(|description| {
613 MarkdownElement::new(description, default_markdown_style(window, cx))
614 }))
615 .children(content.map(|content| {
616 self.render_tool_call_content(entry_ix, content, window, cx)
617 }))
618 .child(
619 h_flex()
620 .justify_end()
621 .gap_1()
622 .child(
623 Button::new(
624 ("always_allow", tool_call_id.as_u64()),
625 "Always Allow Edits",
626 )
627 .icon(IconName::CheckDouble)
628 .icon_position(IconPosition::Start)
629 .icon_size(IconSize::Small)
630 .icon_color(Color::Success)
631 .on_click(cx.listener({
632 let id = tool_call_id;
633 move |this, _, _, cx| {
634 this.authorize_tool_call(
635 id,
636 acp::ToolCallConfirmationOutcome::AlwaysAllow,
637 cx,
638 );
639 }
640 })),
641 )
642 .child(
643 Button::new(("allow", tool_call_id.as_u64()), "Allow")
644 .icon(IconName::Check)
645 .icon_position(IconPosition::Start)
646 .icon_size(IconSize::Small)
647 .icon_color(Color::Success)
648 .on_click(cx.listener({
649 let id = tool_call_id;
650 move |this, _, _, cx| {
651 this.authorize_tool_call(
652 id,
653 acp::ToolCallConfirmationOutcome::Allow,
654 cx,
655 );
656 }
657 })),
658 )
659 .child(
660 Button::new(("reject", tool_call_id.as_u64()), "Reject")
661 .icon(IconName::X)
662 .icon_position(IconPosition::Start)
663 .icon_size(IconSize::Small)
664 .icon_color(Color::Error)
665 .on_click(cx.listener({
666 let id = tool_call_id;
667 move |this, _, _, cx| {
668 this.authorize_tool_call(
669 id,
670 acp::ToolCallConfirmationOutcome::Reject,
671 cx,
672 );
673 }
674 })),
675 ),
676 )
677 .into_any()
678 }
679 ToolCallConfirmation::Execute {
680 command,
681 root_command,
682 description,
683 } => {
684 v_flex()
685 .border_color(cx.theme().colors().border)
686 .border_t_1()
687 .px_2()
688 .py_1p5()
689 // todo! nicer rendering
690 .child(command.clone())
691 .children(description.clone().map(|description| {
692 MarkdownElement::new(description, default_markdown_style(window, cx))
693 }))
694 .children(content.map(|content| {
695 self.render_tool_call_content(entry_ix, content, window, cx)
696 }))
697 .child(
698 h_flex()
699 .justify_end()
700 .gap_1()
701 .child(
702 Button::new(
703 ("always_allow", tool_call_id.as_u64()),
704 format!("Always Allow {root_command}"),
705 )
706 .icon(IconName::CheckDouble)
707 .icon_position(IconPosition::Start)
708 .icon_size(IconSize::Small)
709 .icon_color(Color::Success)
710 .on_click(cx.listener({
711 let id = tool_call_id;
712 move |this, _, _, cx| {
713 this.authorize_tool_call(
714 id,
715 acp::ToolCallConfirmationOutcome::AlwaysAllow,
716 cx,
717 );
718 }
719 })),
720 )
721 .child(
722 Button::new(("allow", tool_call_id.as_u64()), "Allow")
723 .icon(IconName::Check)
724 .icon_position(IconPosition::Start)
725 .icon_size(IconSize::Small)
726 .icon_color(Color::Success)
727 .on_click(cx.listener({
728 let id = tool_call_id;
729 move |this, _, _, cx| {
730 this.authorize_tool_call(
731 id,
732 acp::ToolCallConfirmationOutcome::Allow,
733 cx,
734 );
735 }
736 })),
737 )
738 .child(
739 Button::new(("reject", tool_call_id.as_u64()), "Reject")
740 .icon(IconName::X)
741 .icon_position(IconPosition::Start)
742 .icon_size(IconSize::Small)
743 .icon_color(Color::Error)
744 .on_click(cx.listener({
745 let id = tool_call_id;
746 move |this, _, _, cx| {
747 this.authorize_tool_call(
748 id,
749 acp::ToolCallConfirmationOutcome::Reject,
750 cx,
751 );
752 }
753 })),
754 ),
755 )
756 .into_any()
757 }
758 ToolCallConfirmation::Mcp {
759 server_name,
760 tool_name: _,
761 tool_display_name,
762 description,
763 } => {
764 v_flex()
765 .border_color(cx.theme().colors().border)
766 .border_t_1()
767 .px_2()
768 .py_1p5()
769 // todo! nicer rendering
770 .child(format!("{server_name} - {tool_display_name}"))
771 .children(description.clone().map(|description| {
772 MarkdownElement::new(description, default_markdown_style(window, cx))
773 }))
774 .children(content.map(|content| {
775 self.render_tool_call_content(entry_ix, content, window, cx)
776 }))
777 .child(
778 h_flex()
779 .justify_end()
780 .gap_1()
781 .child(
782 Button::new(
783 ("always_allow_server", tool_call_id.as_u64()),
784 format!("Always Allow {server_name}"),
785 )
786 .icon(IconName::CheckDouble)
787 .icon_position(IconPosition::Start)
788 .icon_size(IconSize::Small)
789 .icon_color(Color::Success)
790 .on_click(cx.listener({
791 let id = tool_call_id;
792 move |this, _, _, cx| {
793 this.authorize_tool_call(
794 id,
795 acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
796 cx,
797 );
798 }
799 })),
800 )
801 .child(
802 Button::new(
803 ("always_allow_tool", tool_call_id.as_u64()),
804 format!("Always Allow {tool_display_name}"),
805 )
806 .icon(IconName::CheckDouble)
807 .icon_position(IconPosition::Start)
808 .icon_size(IconSize::Small)
809 .icon_color(Color::Success)
810 .on_click(cx.listener({
811 let id = tool_call_id;
812 move |this, _, _, cx| {
813 this.authorize_tool_call(
814 id,
815 acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
816 cx,
817 );
818 }
819 })),
820 )
821 .child(
822 Button::new(("allow", tool_call_id.as_u64()), "Allow")
823 .icon(IconName::Check)
824 .icon_position(IconPosition::Start)
825 .icon_size(IconSize::Small)
826 .icon_color(Color::Success)
827 .on_click(cx.listener({
828 let id = tool_call_id;
829 move |this, _, _, cx| {
830 this.authorize_tool_call(
831 id,
832 acp::ToolCallConfirmationOutcome::Allow,
833 cx,
834 );
835 }
836 })),
837 )
838 .child(
839 Button::new(("reject", tool_call_id.as_u64()), "Reject")
840 .icon(IconName::X)
841 .icon_position(IconPosition::Start)
842 .icon_size(IconSize::Small)
843 .icon_color(Color::Error)
844 .on_click(cx.listener({
845 let id = tool_call_id;
846 move |this, _, _, cx| {
847 this.authorize_tool_call(
848 id,
849 acp::ToolCallConfirmationOutcome::Reject,
850 cx,
851 );
852 }
853 })),
854 ),
855 )
856 .into_any()
857 }
858 ToolCallConfirmation::Fetch { description, urls } => v_flex()
859 .border_color(cx.theme().colors().border)
860 .border_t_1()
861 .px_2()
862 .py_1p5()
863 // todo! nicer rendering
864 .children(urls.clone())
865 .children(description.clone().map(|description| {
866 MarkdownElement::new(description, default_markdown_style(window, cx))
867 }))
868 .children(
869 content.map(|content| {
870 self.render_tool_call_content(entry_ix, content, window, cx)
871 }),
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 ToolCallConfirmation::Other { description } => v_flex()
931 .border_color(cx.theme().colors().border)
932 .border_t_1()
933 .px_2()
934 .py_1p5()
935 // todo! nicer rendering
936 .child(MarkdownElement::new(
937 description.clone(),
938 default_markdown_style(window, cx),
939 ))
940 .children(
941 content.map(|content| {
942 self.render_tool_call_content(entry_ix, content, window, cx)
943 }),
944 )
945 .child(
946 h_flex()
947 .justify_end()
948 .gap_1()
949 .child(
950 Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
951 .icon(IconName::CheckDouble)
952 .icon_position(IconPosition::Start)
953 .icon_size(IconSize::Small)
954 .icon_color(Color::Success)
955 .on_click(cx.listener({
956 let id = tool_call_id;
957 move |this, _, _, cx| {
958 this.authorize_tool_call(
959 id,
960 acp::ToolCallConfirmationOutcome::AlwaysAllow,
961 cx,
962 );
963 }
964 })),
965 )
966 .child(
967 Button::new(("allow", tool_call_id.as_u64()), "Allow")
968 .icon(IconName::Check)
969 .icon_position(IconPosition::Start)
970 .icon_size(IconSize::Small)
971 .icon_color(Color::Success)
972 .on_click(cx.listener({
973 let id = tool_call_id;
974 move |this, _, _, cx| {
975 this.authorize_tool_call(
976 id,
977 acp::ToolCallConfirmationOutcome::Allow,
978 cx,
979 );
980 }
981 })),
982 )
983 .child(
984 Button::new(("reject", tool_call_id.as_u64()), "Reject")
985 .icon(IconName::X)
986 .icon_position(IconPosition::Start)
987 .icon_size(IconSize::Small)
988 .icon_color(Color::Error)
989 .on_click(cx.listener({
990 let id = tool_call_id;
991 move |this, _, _, cx| {
992 this.authorize_tool_call(
993 id,
994 acp::ToolCallConfirmationOutcome::Reject,
995 cx,
996 );
997 }
998 })),
999 ),
1000 )
1001 .into_any(),
1002 }
1003 }
1004
1005 fn render_diff_editor(&self, entry_ix: usize, path: &Path) -> AnyElement {
1006 v_flex()
1007 .h_full()
1008 .child(path.to_string_lossy().to_string())
1009 .child(
1010 if let Some(Some(ThreadEntryView::Diff { editor })) =
1011 self.thread_entry_views.get(entry_ix)
1012 {
1013 editor.clone().into_any_element()
1014 } else {
1015 Empty.into_any()
1016 },
1017 )
1018 .into_any()
1019 }
1020}
1021
1022impl Focusable for AcpThreadView {
1023 fn focus_handle(&self, cx: &App) -> FocusHandle {
1024 self.message_editor.focus_handle(cx)
1025 }
1026}
1027
1028impl Render for AcpThreadView {
1029 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1030 let text = self.message_editor.read(cx).text(cx);
1031 let is_editor_empty = text.is_empty();
1032 let focus_handle = self.message_editor.focus_handle(cx);
1033
1034 v_flex()
1035 .key_context("MessageEditor")
1036 .on_action(cx.listener(Self::chat))
1037 .h_full()
1038 .child(match &self.thread_state {
1039 ThreadState::Unauthenticated => v_flex()
1040 .p_2()
1041 .flex_1()
1042 .justify_end()
1043 .child(Label::new("Not authenticated"))
1044 .child(Button::new("sign-in", "Sign in via Gemini CLI").on_click(
1045 cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
1046 )),
1047 ThreadState::Loading { .. } => v_flex()
1048 .p_2()
1049 .flex_1()
1050 .justify_end()
1051 .child(Label::new("Connecting to Gemini...")),
1052 ThreadState::LoadError(e) => div()
1053 .p_2()
1054 .flex_1()
1055 .justify_end()
1056 .child(Label::new(format!("Failed to load: {e}")).into_any_element()),
1057 ThreadState::Ready { thread, .. } => v_flex()
1058 .flex_1()
1059 .gap_2()
1060 .pb_2()
1061 .child(
1062 list(self.list_state.clone())
1063 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
1064 .flex_grow(),
1065 )
1066 .child(
1067 div().px_3().children(match thread.read(cx).status() {
1068 ThreadStatus::Idle => None,
1069 ThreadStatus::WaitingForToolConfirmation => {
1070 Label::new("Waiting for tool confirmation")
1071 .color(Color::Muted)
1072 .size(LabelSize::Small)
1073 .into()
1074 }
1075 ThreadStatus::Generating => Label::new("Generating...")
1076 .color(Color::Muted)
1077 .size(LabelSize::Small)
1078 .into(),
1079 }),
1080 ),
1081 })
1082 .when_some(self.last_error.clone(), |el, error| {
1083 el.child(
1084 div()
1085 .text_xs()
1086 .p_2()
1087 .gap_2()
1088 .border_t_1()
1089 .border_color(cx.theme().status().error_border)
1090 .bg(cx.theme().status().error_background)
1091 .child(MarkdownElement::new(
1092 error,
1093 default_markdown_style(window, cx),
1094 )),
1095 )
1096 })
1097 .child(
1098 v_flex()
1099 .bg(cx.theme().colors().editor_background)
1100 .border_t_1()
1101 .border_color(cx.theme().colors().border)
1102 .p_2()
1103 .gap_2()
1104 .child(self.message_editor.clone())
1105 .child({
1106 let thread = self.thread();
1107
1108 h_flex().justify_end().child(
1109 if thread.map_or(true, |thread| {
1110 thread.read(cx).status() == ThreadStatus::Idle
1111 }) {
1112 IconButton::new("send-message", IconName::Send)
1113 .icon_color(Color::Accent)
1114 .style(ButtonStyle::Filled)
1115 .disabled(thread.is_none() || is_editor_empty)
1116 .on_click({
1117 let focus_handle = focus_handle.clone();
1118 move |_event, window, cx| {
1119 focus_handle.dispatch_action(&Chat, window, cx);
1120 }
1121 })
1122 .when(!is_editor_empty, |button| {
1123 button.tooltip(move |window, cx| {
1124 Tooltip::for_action("Send", &Chat, window, cx)
1125 })
1126 })
1127 .when(is_editor_empty, |button| {
1128 button.tooltip(Tooltip::text("Type a message to submit"))
1129 })
1130 } else {
1131 IconButton::new("stop-generation", IconName::StopFilled)
1132 .icon_color(Color::Error)
1133 .style(ButtonStyle::Tinted(ui::TintColor::Error))
1134 .tooltip(move |window, cx| {
1135 Tooltip::for_action(
1136 "Stop Generation",
1137 &editor::actions::Cancel,
1138 window,
1139 cx,
1140 )
1141 })
1142 .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
1143 },
1144 )
1145 }),
1146 )
1147 }
1148}
1149
1150fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1151 let mut style = default_markdown_style(window, cx);
1152 let mut text_style = window.text_style();
1153 let theme_settings = ThemeSettings::get_global(cx);
1154
1155 let buffer_font = theme_settings.buffer_font.family.clone();
1156 let buffer_font_size = TextSize::Small.rems(cx);
1157
1158 text_style.refine(&TextStyleRefinement {
1159 font_family: Some(buffer_font),
1160 font_size: Some(buffer_font_size.into()),
1161 ..Default::default()
1162 });
1163
1164 style.base_text_style = text_style;
1165 style
1166}
1167
1168fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1169 let theme_settings = ThemeSettings::get_global(cx);
1170 let colors = cx.theme().colors();
1171 let ui_font_size = TextSize::Default.rems(cx);
1172 let buffer_font_size = TextSize::Small.rems(cx);
1173 let mut text_style = window.text_style();
1174 let line_height = buffer_font_size * 1.75;
1175
1176 text_style.refine(&TextStyleRefinement {
1177 font_family: Some(theme_settings.ui_font.family.clone()),
1178 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1179 font_features: Some(theme_settings.ui_font.features.clone()),
1180 font_size: Some(ui_font_size.into()),
1181 line_height: Some(line_height.into()),
1182 color: Some(cx.theme().colors().text),
1183 ..Default::default()
1184 });
1185
1186 MarkdownStyle {
1187 base_text_style: text_style.clone(),
1188 syntax: cx.theme().syntax().clone(),
1189 selection_background_color: cx.theme().colors().element_selection_background,
1190 code_block_overflow_x_scroll: true,
1191 table_overflow_x_scroll: true,
1192 heading_level_styles: Some(HeadingLevelStyles {
1193 h1: Some(TextStyleRefinement {
1194 font_size: Some(rems(1.15).into()),
1195 ..Default::default()
1196 }),
1197 h2: Some(TextStyleRefinement {
1198 font_size: Some(rems(1.1).into()),
1199 ..Default::default()
1200 }),
1201 h3: Some(TextStyleRefinement {
1202 font_size: Some(rems(1.05).into()),
1203 ..Default::default()
1204 }),
1205 h4: Some(TextStyleRefinement {
1206 font_size: Some(rems(1.).into()),
1207 ..Default::default()
1208 }),
1209 h5: Some(TextStyleRefinement {
1210 font_size: Some(rems(0.95).into()),
1211 ..Default::default()
1212 }),
1213 h6: Some(TextStyleRefinement {
1214 font_size: Some(rems(0.875).into()),
1215 ..Default::default()
1216 }),
1217 }),
1218 code_block: StyleRefinement {
1219 padding: EdgesRefinement {
1220 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1221 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1222 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1223 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1224 },
1225 background: Some(colors.editor_background.into()),
1226 text: Some(TextStyleRefinement {
1227 font_family: Some(theme_settings.buffer_font.family.clone()),
1228 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1229 font_features: Some(theme_settings.buffer_font.features.clone()),
1230 font_size: Some(buffer_font_size.into()),
1231 ..Default::default()
1232 }),
1233 ..Default::default()
1234 },
1235 inline_code: TextStyleRefinement {
1236 font_family: Some(theme_settings.buffer_font.family.clone()),
1237 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1238 font_features: Some(theme_settings.buffer_font.features.clone()),
1239 font_size: Some(buffer_font_size.into()),
1240 background_color: Some(colors.editor_foreground.opacity(0.08)),
1241 ..Default::default()
1242 },
1243 link: TextStyleRefinement {
1244 background_color: Some(colors.editor_foreground.opacity(0.025)),
1245 underline: Some(UnderlineStyle {
1246 color: Some(colors.text_accent.opacity(0.5)),
1247 thickness: px(1.),
1248 ..Default::default()
1249 }),
1250 ..Default::default()
1251 },
1252 link_callback: Some(Rc::new(move |_url, _cx| {
1253 // todo!()
1254 // if MentionLink::is_valid(url) {
1255 // let colors = cx.theme().colors();
1256 // Some(TextStyleRefinement {
1257 // background_color: Some(colors.element_background),
1258 // ..Default::default()
1259 // })
1260 // } else {
1261 None
1262 // }
1263 })),
1264 ..Default::default()
1265 }
1266}