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