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 collections::{HashMap, HashSet};
8use editor::{
9 AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
10 EditorStyle, MinimapVisibility, MultiBuffer,
11};
12use file_icons::FileIcons;
13use futures::channel::oneshot;
14use gpui::{
15 Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, Focusable,
16 Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement, Subscription, TextStyle,
17 TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, div, list, percentage,
18 prelude::*, pulsating_between,
19};
20use gpui::{FocusHandle, Task};
21use language::language_settings::SoftWrap;
22use language::{Buffer, Language};
23use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
24use parking_lot::Mutex;
25use project::Project;
26use settings::Settings as _;
27use theme::ThemeSettings;
28use ui::{Disclosure, Tooltip, prelude::*};
29use util::ResultExt;
30use workspace::Workspace;
31use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
32
33use ::acp::{
34 AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff,
35 LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent,
36 ToolCallId, ToolCallStatus,
37};
38
39use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
40use crate::acp::message_history::MessageHistory;
41
42const RESPONSE_PADDING_X: Pixels = px(19.);
43
44pub struct AcpThreadView {
45 workspace: WeakEntity<Workspace>,
46 project: Entity<Project>,
47 thread_state: ThreadState,
48 diff_editors: HashMap<EntityId, Entity<Editor>>,
49 message_editor: Entity<Editor>,
50 mention_set: Arc<Mutex<MentionSet>>,
51 last_error: Option<Entity<Markdown>>,
52 list_state: ListState,
53 auth_task: Option<Task<()>>,
54 expanded_tool_calls: HashSet<ToolCallId>,
55 expanded_thinking_blocks: HashSet<(usize, usize)>,
56 message_history: MessageHistory<acp::UserMessage>,
57}
58
59enum ThreadState {
60 Loading {
61 _task: Task<()>,
62 },
63 Ready {
64 thread: Entity<AcpThread>,
65 _subscription: Subscription,
66 },
67 LoadError(LoadError),
68 Unauthenticated {
69 thread: Entity<AcpThread>,
70 },
71}
72
73impl AcpThreadView {
74 pub fn new(
75 workspace: WeakEntity<Workspace>,
76 project: Entity<Project>,
77 window: &mut Window,
78 cx: &mut Context<Self>,
79 ) -> Self {
80 let language = Language::new(
81 language::LanguageConfig {
82 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
83 ..Default::default()
84 },
85 None,
86 );
87
88 let mention_set = Arc::new(Mutex::new(MentionSet::default()));
89
90 let message_editor = cx.new(|cx| {
91 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
92 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
93
94 let mut editor = Editor::new(
95 editor::EditorMode::AutoHeight {
96 min_lines: 4,
97 max_lines: None,
98 },
99 buffer,
100 None,
101 window,
102 cx,
103 );
104 editor.set_placeholder_text("Message the agent - @ to include files", cx);
105 editor.set_show_indent_guides(false, cx);
106 editor.set_soft_wrap();
107 editor.set_use_modal_editing(true);
108 editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
109 mention_set.clone(),
110 workspace.clone(),
111 cx.weak_entity(),
112 ))));
113 editor.set_context_menu_options(ContextMenuOptions {
114 min_entries_visible: 12,
115 max_entries_visible: 12,
116 placement: Some(ContextMenuPlacement::Above),
117 });
118 editor
119 });
120
121 let list_state = ListState::new(
122 0,
123 gpui::ListAlignment::Bottom,
124 px(2048.0),
125 cx.processor({
126 move |this: &mut Self, index: usize, window, cx| {
127 let Some((entry, len)) = this.thread().and_then(|thread| {
128 let entries = &thread.read(cx).entries();
129 Some((entries.get(index)?, entries.len()))
130 }) else {
131 return Empty.into_any();
132 };
133 this.render_entry(index, len, entry, window, cx)
134 }
135 }),
136 );
137
138 Self {
139 workspace,
140 project: project.clone(),
141 thread_state: Self::initial_state(project, window, cx),
142 message_editor,
143 mention_set,
144 diff_editors: Default::default(),
145 list_state: list_state,
146 last_error: None,
147 auth_task: None,
148 expanded_tool_calls: HashSet::default(),
149 expanded_thinking_blocks: HashSet::default(),
150 message_history: MessageHistory::new(),
151 }
152 }
153
154 fn initial_state(
155 project: Entity<Project>,
156 window: &mut Window,
157 cx: &mut Context<Self>,
158 ) -> ThreadState {
159 let root_dir = project
160 .read(cx)
161 .visible_worktrees(cx)
162 .next()
163 .map(|worktree| worktree.read(cx).abs_path())
164 .unwrap_or_else(|| paths::home_dir().as_path().into());
165
166 let load_task = cx.spawn_in(window, async move |this, cx| {
167 let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await
168 {
169 Ok(thread) => thread,
170 Err(err) => {
171 this.update(cx, |this, cx| {
172 this.handle_load_error(err, cx);
173 cx.notify();
174 })
175 .log_err();
176 return;
177 }
178 };
179
180 let init_response = async {
181 let resp = thread
182 .read_with(cx, |thread, _cx| thread.initialize())?
183 .await?;
184 anyhow::Ok(resp)
185 };
186
187 let result = match init_response.await {
188 Err(e) => {
189 let mut cx = cx.clone();
190 if e.downcast_ref::<oneshot::Canceled>().is_some() {
191 let child_status = thread
192 .update(&mut cx, |thread, _| thread.child_status())
193 .ok()
194 .flatten();
195 if let Some(child_status) = child_status {
196 match child_status.await {
197 Ok(_) => Err(e),
198 Err(e) => Err(e),
199 }
200 } else {
201 Err(e)
202 }
203 } else {
204 Err(e)
205 }
206 }
207 Ok(response) => {
208 if !response.is_authenticated {
209 this.update(cx, |this, _| {
210 this.thread_state = ThreadState::Unauthenticated { thread };
211 })
212 .ok();
213 return;
214 };
215 Ok(())
216 }
217 };
218
219 this.update_in(cx, |this, window, cx| {
220 match result {
221 Ok(()) => {
222 let subscription =
223 cx.subscribe_in(&thread, window, Self::handle_thread_event);
224 this.list_state
225 .splice(0..0, thread.read(cx).entries().len());
226
227 this.thread_state = ThreadState::Ready {
228 thread,
229 _subscription: subscription,
230 };
231 cx.notify();
232 }
233 Err(err) => {
234 this.handle_load_error(err, cx);
235 }
236 };
237 })
238 .log_err();
239 });
240
241 ThreadState::Loading { _task: load_task }
242 }
243
244 fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context<Self>) {
245 if let Some(load_err) = err.downcast_ref::<LoadError>() {
246 self.thread_state = ThreadState::LoadError(load_err.clone());
247 } else {
248 self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
249 }
250 cx.notify();
251 }
252
253 fn thread(&self) -> Option<&Entity<AcpThread>> {
254 match &self.thread_state {
255 ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
256 Some(thread)
257 }
258 ThreadState::Loading { .. } | ThreadState::LoadError(..) => None,
259 }
260 }
261
262 pub fn title(&self, cx: &App) -> SharedString {
263 match &self.thread_state {
264 ThreadState::Ready { thread, .. } => thread.read(cx).title(),
265 ThreadState::Loading { .. } => "Loading…".into(),
266 ThreadState::LoadError(_) => "Failed to load".into(),
267 ThreadState::Unauthenticated { .. } => "Not authenticated".into(),
268 }
269 }
270
271 pub fn cancel(&mut self, cx: &mut Context<Self>) {
272 self.last_error.take();
273
274 if let Some(thread) = self.thread() {
275 thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
276 }
277 }
278
279 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
280 self.last_error.take();
281
282 let mut ix = 0;
283 let mut chunks: Vec<acp::UserMessageChunk> = Vec::new();
284
285 let project = self.project.clone();
286 self.message_editor.update(cx, |editor, cx| {
287 let text = editor.text(cx);
288 editor.display_map.update(cx, |map, cx| {
289 let snapshot = map.snapshot(cx);
290 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
291 if let Some(project_path) =
292 self.mention_set.lock().path_for_crease_id(crease_id)
293 {
294 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
295 if crease_range.start > ix {
296 chunks.push(acp::UserMessageChunk::Text {
297 chunk: text[ix..crease_range.start].to_string(),
298 });
299 }
300 if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) {
301 chunks.push(acp::UserMessageChunk::Path { path: abs_path });
302 }
303 ix = crease_range.end;
304 }
305 }
306
307 if ix < text.len() {
308 let last_chunk = text[ix..].trim();
309 if !last_chunk.is_empty() {
310 chunks.push(last_chunk.into());
311 }
312 }
313 })
314 });
315
316 if chunks.is_empty() {
317 return;
318 }
319
320 let Some(thread) = self.thread() else { return };
321 let message = acp::UserMessage { chunks };
322 let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx));
323
324 cx.spawn(async move |this, cx| {
325 let result = task.await;
326
327 this.update(cx, |this, cx| {
328 if let Err(err) = result {
329 this.last_error =
330 Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx)))
331 }
332 })
333 })
334 .detach();
335
336 let mention_set = self.mention_set.clone();
337
338 self.message_editor.update(cx, |editor, cx| {
339 editor.clear(window, cx);
340 editor.remove_creases(mention_set.lock().drain(), cx)
341 });
342
343 self.message_history.push(message);
344 }
345
346 fn previous_history_message(
347 &mut self,
348 _: &PreviousHistoryMessage,
349 window: &mut Window,
350 cx: &mut Context<Self>,
351 ) {
352 Self::set_draft_message(
353 self.message_editor.clone(),
354 self.mention_set.clone(),
355 self.project.clone(),
356 self.message_history.prev(),
357 window,
358 cx,
359 );
360 }
361
362 fn next_history_message(
363 &mut self,
364 _: &NextHistoryMessage,
365 window: &mut Window,
366 cx: &mut Context<Self>,
367 ) {
368 Self::set_draft_message(
369 self.message_editor.clone(),
370 self.mention_set.clone(),
371 self.project.clone(),
372 self.message_history.next(),
373 window,
374 cx,
375 );
376 }
377
378 fn set_draft_message(
379 message_editor: Entity<Editor>,
380 mention_set: Arc<Mutex<MentionSet>>,
381 project: Entity<Project>,
382 message: Option<&acp::UserMessage>,
383 window: &mut Window,
384 cx: &mut Context<Self>,
385 ) {
386 cx.notify();
387
388 let Some(message) = message else {
389 message_editor.update(cx, |editor, cx| {
390 editor.clear(window, cx);
391 editor.remove_creases(mention_set.lock().drain(), cx)
392 });
393 return;
394 };
395
396 let mut text = String::new();
397 let mut mentions = Vec::new();
398
399 for chunk in &message.chunks {
400 match chunk {
401 acp::UserMessageChunk::Text { chunk } => {
402 text.push_str(&chunk);
403 }
404 acp::UserMessageChunk::Path { path } => {
405 let start = text.len();
406 let content = MentionPath::new(path).to_string();
407 text.push_str(&content);
408 let end = text.len();
409 if let Some(project_path) =
410 project.read(cx).project_path_for_absolute_path(path, cx)
411 {
412 let filename: SharedString = path
413 .file_name()
414 .unwrap_or_default()
415 .to_string_lossy()
416 .to_string()
417 .into();
418 mentions.push((start..end, project_path, filename));
419 }
420 }
421 }
422 }
423
424 let snapshot = message_editor.update(cx, |editor, cx| {
425 editor.set_text(text, window, cx);
426 editor.buffer().read(cx).snapshot(cx)
427 });
428
429 for (range, project_path, filename) in mentions {
430 let crease_icon_path = if project_path.path.is_dir() {
431 FileIcons::get_folder_icon(false, cx)
432 .unwrap_or_else(|| IconName::Folder.path().into())
433 } else {
434 FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
435 .unwrap_or_else(|| IconName::File.path().into())
436 };
437
438 let anchor = snapshot.anchor_before(range.start);
439 let crease_id = crate::context_picker::insert_crease_for_mention(
440 anchor.excerpt_id,
441 anchor.text_anchor,
442 range.end - range.start,
443 filename,
444 crease_icon_path,
445 message_editor.clone(),
446 window,
447 cx,
448 );
449 if let Some(crease_id) = crease_id {
450 mention_set.lock().insert(crease_id, project_path);
451 }
452 }
453 }
454
455 fn handle_thread_event(
456 &mut self,
457 thread: &Entity<AcpThread>,
458 event: &AcpThreadEvent,
459 window: &mut Window,
460 cx: &mut Context<Self>,
461 ) {
462 let count = self.list_state.item_count();
463 match event {
464 AcpThreadEvent::NewEntry => {
465 self.sync_thread_entry_view(thread.read(cx).entries().len() - 1, window, cx);
466 self.list_state.splice(count..count, 1);
467 }
468 AcpThreadEvent::EntryUpdated(index) => {
469 let index = *index;
470 self.sync_thread_entry_view(index, window, cx);
471 self.list_state.splice(index..index + 1, 1);
472 }
473 }
474 cx.notify();
475 }
476
477 fn sync_thread_entry_view(
478 &mut self,
479 entry_ix: usize,
480 window: &mut Window,
481 cx: &mut Context<Self>,
482 ) {
483 let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else {
484 return;
485 };
486
487 if self.diff_editors.contains_key(&multibuffer.entity_id()) {
488 return;
489 }
490
491 let editor = cx.new(|cx| {
492 let mut editor = Editor::new(
493 EditorMode::Full {
494 scale_ui_elements_with_buffer_font_size: false,
495 show_active_line_background: false,
496 sized_by_content: true,
497 },
498 multibuffer.clone(),
499 None,
500 window,
501 cx,
502 );
503 editor.set_show_gutter(false, cx);
504 editor.disable_inline_diagnostics();
505 editor.disable_expand_excerpt_buttons(cx);
506 editor.set_show_vertical_scrollbar(false, cx);
507 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
508 editor.set_soft_wrap_mode(SoftWrap::None, cx);
509 editor.scroll_manager.set_forbid_vertical_scroll(true);
510 editor.set_show_indent_guides(false, cx);
511 editor.set_read_only(true);
512 editor.set_show_breakpoints(false, cx);
513 editor.set_show_code_actions(false, cx);
514 editor.set_show_git_diff_gutter(false, cx);
515 editor.set_expand_all_diff_hunks(cx);
516 editor.set_text_style_refinement(TextStyleRefinement {
517 font_size: Some(
518 TextSize::Small
519 .rems(cx)
520 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
521 .into(),
522 ),
523 ..Default::default()
524 });
525 editor
526 });
527 let entity_id = multibuffer.entity_id();
528 cx.observe_release(&multibuffer, move |this, _, _| {
529 this.diff_editors.remove(&entity_id);
530 })
531 .detach();
532
533 self.diff_editors.insert(entity_id, editor);
534 }
535
536 fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
537 let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
538 if let AgentThreadEntry::ToolCall(ToolCall {
539 content: Some(ToolCallContent::Diff { diff }),
540 ..
541 }) = &entry
542 {
543 Some(diff.multibuffer.clone())
544 } else {
545 None
546 }
547 }
548
549 fn authenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
550 let Some(thread) = self.thread().cloned() else {
551 return;
552 };
553
554 self.last_error.take();
555 let authenticate = thread.read(cx).authenticate();
556 self.auth_task = Some(cx.spawn_in(window, {
557 let project = self.project.clone();
558 async move |this, cx| {
559 let result = authenticate.await;
560
561 this.update_in(cx, |this, window, cx| {
562 if let Err(err) = result {
563 this.last_error = Some(cx.new(|cx| {
564 Markdown::new(format!("Error: {err}").into(), None, None, cx)
565 }))
566 } else {
567 this.thread_state = Self::initial_state(project.clone(), window, cx)
568 }
569 this.auth_task.take()
570 })
571 .ok();
572 }
573 }));
574 }
575
576 fn authorize_tool_call(
577 &mut self,
578 id: ToolCallId,
579 outcome: acp::ToolCallConfirmationOutcome,
580 cx: &mut Context<Self>,
581 ) {
582 let Some(thread) = self.thread() else {
583 return;
584 };
585 thread.update(cx, |thread, cx| {
586 thread.authorize_tool_call(id, outcome, cx);
587 });
588 cx.notify();
589 }
590
591 fn render_entry(
592 &self,
593 index: usize,
594 total_entries: usize,
595 entry: &AgentThreadEntry,
596 window: &mut Window,
597 cx: &Context<Self>,
598 ) -> AnyElement {
599 match &entry {
600 AgentThreadEntry::UserMessage(message) => div()
601 .py_4()
602 .px_2()
603 .child(
604 v_flex()
605 .p_3()
606 .gap_1p5()
607 .rounded_lg()
608 .shadow_md()
609 .bg(cx.theme().colors().editor_background)
610 .border_1()
611 .border_color(cx.theme().colors().border)
612 .text_xs()
613 .child(self.render_markdown(
614 message.content.clone(),
615 user_message_markdown_style(window, cx),
616 )),
617 )
618 .into_any(),
619 AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
620 let style = default_markdown_style(false, window, cx);
621 let message_body = v_flex()
622 .w_full()
623 .gap_2p5()
624 .children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| {
625 match chunk {
626 AssistantMessageChunk::Text { chunk } => self
627 .render_markdown(chunk.clone(), style.clone())
628 .into_any_element(),
629 AssistantMessageChunk::Thought { chunk } => self.render_thinking_block(
630 index,
631 chunk_ix,
632 chunk.clone(),
633 window,
634 cx,
635 ),
636 }
637 }))
638 .into_any();
639
640 v_flex()
641 .px_5()
642 .py_1()
643 .when(index + 1 == total_entries, |this| this.pb_4())
644 .w_full()
645 .text_ui(cx)
646 .child(message_body)
647 .into_any()
648 }
649 AgentThreadEntry::ToolCall(tool_call) => div()
650 .py_1p5()
651 .px_5()
652 .child(self.render_tool_call(index, tool_call, window, cx))
653 .into_any(),
654 }
655 }
656
657 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
658 cx.theme()
659 .colors()
660 .element_background
661 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
662 }
663
664 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
665 cx.theme().colors().border.opacity(0.6)
666 }
667
668 fn tool_name_font_size(&self) -> Rems {
669 rems_from_px(13.)
670 }
671
672 fn render_thinking_block(
673 &self,
674 entry_ix: usize,
675 chunk_ix: usize,
676 chunk: Entity<Markdown>,
677 window: &Window,
678 cx: &Context<Self>,
679 ) -> AnyElement {
680 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
681 let key = (entry_ix, chunk_ix);
682 let is_open = self.expanded_thinking_blocks.contains(&key);
683
684 v_flex()
685 .child(
686 h_flex()
687 .id(header_id)
688 .group("disclosure-header")
689 .w_full()
690 .justify_between()
691 .opacity(0.8)
692 .hover(|style| style.opacity(1.))
693 .child(
694 h_flex()
695 .gap_1p5()
696 .child(
697 Icon::new(IconName::ToolBulb)
698 .size(IconSize::Small)
699 .color(Color::Muted),
700 )
701 .child(
702 div()
703 .text_size(self.tool_name_font_size())
704 .child("Thinking"),
705 ),
706 )
707 .child(
708 div().visible_on_hover("disclosure-header").child(
709 Disclosure::new("thinking-disclosure", is_open)
710 .opened_icon(IconName::ChevronUp)
711 .closed_icon(IconName::ChevronDown)
712 .on_click(cx.listener({
713 move |this, _event, _window, cx| {
714 if is_open {
715 this.expanded_thinking_blocks.remove(&key);
716 } else {
717 this.expanded_thinking_blocks.insert(key);
718 }
719 cx.notify();
720 }
721 })),
722 ),
723 )
724 .on_click(cx.listener({
725 move |this, _event, _window, cx| {
726 if is_open {
727 this.expanded_thinking_blocks.remove(&key);
728 } else {
729 this.expanded_thinking_blocks.insert(key);
730 }
731 cx.notify();
732 }
733 })),
734 )
735 .when(is_open, |this| {
736 this.child(
737 div()
738 .relative()
739 .mt_1p5()
740 .ml(px(7.))
741 .pl_4()
742 .border_l_1()
743 .border_color(self.tool_card_border_color(cx))
744 .text_ui_sm(cx)
745 .child(
746 self.render_markdown(chunk, default_markdown_style(false, window, cx)),
747 ),
748 )
749 })
750 .into_any_element()
751 }
752
753 fn render_tool_call(
754 &self,
755 entry_ix: usize,
756 tool_call: &ToolCall,
757 window: &Window,
758 cx: &Context<Self>,
759 ) -> Div {
760 let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix));
761
762 let status_icon = match &tool_call.status {
763 ToolCallStatus::WaitingForConfirmation { .. } => None,
764 ToolCallStatus::Allowed {
765 status: acp::ToolCallStatus::Running,
766 ..
767 } => Some(
768 Icon::new(IconName::ArrowCircle)
769 .color(Color::Accent)
770 .size(IconSize::Small)
771 .with_animation(
772 "running",
773 Animation::new(Duration::from_secs(2)).repeat(),
774 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
775 )
776 .into_any(),
777 ),
778 ToolCallStatus::Allowed {
779 status: acp::ToolCallStatus::Finished,
780 ..
781 } => None,
782 ToolCallStatus::Rejected
783 | ToolCallStatus::Canceled
784 | ToolCallStatus::Allowed {
785 status: acp::ToolCallStatus::Error,
786 ..
787 } => Some(
788 Icon::new(IconName::X)
789 .color(Color::Error)
790 .size(IconSize::Small)
791 .into_any_element(),
792 ),
793 };
794
795 let needs_confirmation = match &tool_call.status {
796 ToolCallStatus::WaitingForConfirmation { .. } => true,
797 _ => tool_call
798 .content
799 .iter()
800 .any(|content| matches!(content, ToolCallContent::Diff { .. })),
801 };
802
803 let is_collapsible = tool_call.content.is_some() && !needs_confirmation;
804 let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id);
805
806 let content = if is_open {
807 match &tool_call.status {
808 ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
809 Some(self.render_tool_call_confirmation(
810 tool_call.id,
811 confirmation,
812 tool_call.content.as_ref(),
813 window,
814 cx,
815 ))
816 }
817 ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
818 tool_call.content.as_ref().map(|content| {
819 div()
820 .py_1p5()
821 .child(self.render_tool_call_content(content, window, cx))
822 .into_any_element()
823 })
824 }
825 ToolCallStatus::Rejected => None,
826 }
827 } else {
828 None
829 };
830
831 v_flex()
832 .when(needs_confirmation, |this| {
833 this.rounded_lg()
834 .border_1()
835 .border_color(self.tool_card_border_color(cx))
836 .bg(cx.theme().colors().editor_background)
837 .overflow_hidden()
838 })
839 .child(
840 h_flex()
841 .id(header_id)
842 .w_full()
843 .gap_1()
844 .justify_between()
845 .map(|this| {
846 if needs_confirmation {
847 this.px_2()
848 .py_1()
849 .rounded_t_md()
850 .bg(self.tool_card_header_bg(cx))
851 .border_b_1()
852 .border_color(self.tool_card_border_color(cx))
853 } else {
854 this.opacity(0.8).hover(|style| style.opacity(1.))
855 }
856 })
857 .child(
858 h_flex()
859 .id("tool-call-header")
860 .overflow_x_scroll()
861 .map(|this| {
862 if needs_confirmation {
863 this.text_xs()
864 } else {
865 this.text_size(self.tool_name_font_size())
866 }
867 })
868 .gap_1p5()
869 .child(
870 Icon::new(tool_call.icon)
871 .size(IconSize::Small)
872 .color(Color::Muted),
873 )
874 .child(self.render_markdown(
875 tool_call.label.clone(),
876 default_markdown_style(needs_confirmation, window, cx),
877 )),
878 )
879 .child(
880 h_flex()
881 .gap_0p5()
882 .when(is_collapsible, |this| {
883 this.child(
884 Disclosure::new(("expand", tool_call.id.0), is_open)
885 .opened_icon(IconName::ChevronUp)
886 .closed_icon(IconName::ChevronDown)
887 .on_click(cx.listener({
888 let id = tool_call.id;
889 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
890 if is_open {
891 this.expanded_tool_calls.remove(&id);
892 } else {
893 this.expanded_tool_calls.insert(id);
894 }
895 cx.notify();
896 }
897 })),
898 )
899 })
900 .children(status_icon),
901 )
902 .on_click(cx.listener({
903 let id = tool_call.id;
904 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
905 if is_open {
906 this.expanded_tool_calls.remove(&id);
907 } else {
908 this.expanded_tool_calls.insert(id);
909 }
910 cx.notify();
911 }
912 })),
913 )
914 .when(is_open, |this| {
915 this.child(
916 div()
917 .text_xs()
918 .when(is_collapsible, |this| {
919 this.mt_1()
920 .border_1()
921 .border_color(self.tool_card_border_color(cx))
922 .bg(cx.theme().colors().editor_background)
923 .rounded_lg()
924 })
925 .children(content),
926 )
927 })
928 }
929
930 fn render_tool_call_content(
931 &self,
932 content: &ToolCallContent,
933 window: &Window,
934 cx: &Context<Self>,
935 ) -> AnyElement {
936 match content {
937 ToolCallContent::Markdown { markdown } => self
938 .render_markdown(markdown.clone(), default_markdown_style(false, window, cx))
939 .into_any_element(),
940 ToolCallContent::Diff {
941 diff: Diff {
942 path, multibuffer, ..
943 },
944 ..
945 } => self.render_diff_editor(multibuffer, path),
946 }
947 }
948
949 fn render_tool_call_confirmation(
950 &self,
951 tool_call_id: ToolCallId,
952 confirmation: &ToolCallConfirmation,
953 content: Option<&ToolCallContent>,
954 window: &Window,
955 cx: &Context<Self>,
956 ) -> AnyElement {
957 let confirmation_container = v_flex().mt_1().py_1p5();
958
959 let button_container = h_flex()
960 .pt_1p5()
961 .px_1p5()
962 .gap_1()
963 .justify_end()
964 .border_t_1()
965 .border_color(self.tool_card_border_color(cx));
966
967 match confirmation {
968 ToolCallConfirmation::Edit { description } => confirmation_container
969 .child(
970 div()
971 .px_2()
972 .children(description.clone().map(|description| {
973 self.render_markdown(
974 description,
975 default_markdown_style(false, window, cx),
976 )
977 })),
978 )
979 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
980 .child(
981 button_container
982 .child(
983 Button::new(("always_allow", tool_call_id.0), "Always Allow Edits")
984 .icon(IconName::CheckDouble)
985 .icon_position(IconPosition::Start)
986 .icon_size(IconSize::XSmall)
987 .icon_color(Color::Success)
988 .on_click(cx.listener({
989 let id = tool_call_id;
990 move |this, _, _, cx| {
991 this.authorize_tool_call(
992 id,
993 acp::ToolCallConfirmationOutcome::AlwaysAllow,
994 cx,
995 );
996 }
997 })),
998 )
999 .child(
1000 Button::new(("allow", tool_call_id.0), "Allow")
1001 .icon(IconName::Check)
1002 .icon_position(IconPosition::Start)
1003 .icon_size(IconSize::XSmall)
1004 .icon_color(Color::Success)
1005 .on_click(cx.listener({
1006 let id = tool_call_id;
1007 move |this, _, _, cx| {
1008 this.authorize_tool_call(
1009 id,
1010 acp::ToolCallConfirmationOutcome::Allow,
1011 cx,
1012 );
1013 }
1014 })),
1015 )
1016 .child(
1017 Button::new(("reject", tool_call_id.0), "Reject")
1018 .icon(IconName::X)
1019 .icon_position(IconPosition::Start)
1020 .icon_size(IconSize::XSmall)
1021 .icon_color(Color::Error)
1022 .on_click(cx.listener({
1023 let id = tool_call_id;
1024 move |this, _, _, cx| {
1025 this.authorize_tool_call(
1026 id,
1027 acp::ToolCallConfirmationOutcome::Reject,
1028 cx,
1029 );
1030 }
1031 })),
1032 ),
1033 )
1034 .into_any(),
1035 ToolCallConfirmation::Execute {
1036 command,
1037 root_command,
1038 description,
1039 } => confirmation_container
1040 .child(v_flex().px_2().pb_1p5().child(command.clone()).children(
1041 description.clone().map(|description| {
1042 self.render_markdown(description, default_markdown_style(false, window, cx))
1043 .on_url_click({
1044 let workspace = self.workspace.clone();
1045 move |text, window, cx| {
1046 Self::open_link(text, &workspace, window, cx);
1047 }
1048 })
1049 }),
1050 ))
1051 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1052 .child(
1053 button_container
1054 .child(
1055 Button::new(
1056 ("always_allow", tool_call_id.0),
1057 format!("Always Allow {root_command}"),
1058 )
1059 .icon(IconName::CheckDouble)
1060 .icon_position(IconPosition::Start)
1061 .icon_size(IconSize::XSmall)
1062 .icon_color(Color::Success)
1063 .label_size(LabelSize::Small)
1064 .on_click(cx.listener({
1065 let id = tool_call_id;
1066 move |this, _, _, cx| {
1067 this.authorize_tool_call(
1068 id,
1069 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1070 cx,
1071 );
1072 }
1073 })),
1074 )
1075 .child(
1076 Button::new(("allow", tool_call_id.0), "Allow")
1077 .icon(IconName::Check)
1078 .icon_position(IconPosition::Start)
1079 .icon_size(IconSize::XSmall)
1080 .icon_color(Color::Success)
1081 .label_size(LabelSize::Small)
1082 .on_click(cx.listener({
1083 let id = tool_call_id;
1084 move |this, _, _, cx| {
1085 this.authorize_tool_call(
1086 id,
1087 acp::ToolCallConfirmationOutcome::Allow,
1088 cx,
1089 );
1090 }
1091 })),
1092 )
1093 .child(
1094 Button::new(("reject", tool_call_id.0), "Reject")
1095 .icon(IconName::X)
1096 .icon_position(IconPosition::Start)
1097 .icon_size(IconSize::XSmall)
1098 .icon_color(Color::Error)
1099 .label_size(LabelSize::Small)
1100 .on_click(cx.listener({
1101 let id = tool_call_id;
1102 move |this, _, _, cx| {
1103 this.authorize_tool_call(
1104 id,
1105 acp::ToolCallConfirmationOutcome::Reject,
1106 cx,
1107 );
1108 }
1109 })),
1110 ),
1111 )
1112 .into_any(),
1113 ToolCallConfirmation::Mcp {
1114 server_name,
1115 tool_name: _,
1116 tool_display_name,
1117 description,
1118 } => confirmation_container
1119 .child(
1120 v_flex()
1121 .px_2()
1122 .pb_1p5()
1123 .child(format!("{server_name} - {tool_display_name}"))
1124 .children(description.clone().map(|description| {
1125 self.render_markdown(
1126 description,
1127 default_markdown_style(false, window, cx),
1128 )
1129 })),
1130 )
1131 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1132 .child(
1133 button_container
1134 .child(
1135 Button::new(
1136 ("always_allow_server", tool_call_id.0),
1137 format!("Always Allow {server_name}"),
1138 )
1139 .icon(IconName::CheckDouble)
1140 .icon_position(IconPosition::Start)
1141 .icon_size(IconSize::XSmall)
1142 .icon_color(Color::Success)
1143 .label_size(LabelSize::Small)
1144 .on_click(cx.listener({
1145 let id = tool_call_id;
1146 move |this, _, _, cx| {
1147 this.authorize_tool_call(
1148 id,
1149 acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
1150 cx,
1151 );
1152 }
1153 })),
1154 )
1155 .child(
1156 Button::new(
1157 ("always_allow_tool", tool_call_id.0),
1158 format!("Always Allow {tool_display_name}"),
1159 )
1160 .icon(IconName::CheckDouble)
1161 .icon_position(IconPosition::Start)
1162 .icon_size(IconSize::XSmall)
1163 .icon_color(Color::Success)
1164 .label_size(LabelSize::Small)
1165 .on_click(cx.listener({
1166 let id = tool_call_id;
1167 move |this, _, _, cx| {
1168 this.authorize_tool_call(
1169 id,
1170 acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
1171 cx,
1172 );
1173 }
1174 })),
1175 )
1176 .child(
1177 Button::new(("allow", tool_call_id.0), "Allow")
1178 .icon(IconName::Check)
1179 .icon_position(IconPosition::Start)
1180 .icon_size(IconSize::XSmall)
1181 .icon_color(Color::Success)
1182 .label_size(LabelSize::Small)
1183 .on_click(cx.listener({
1184 let id = tool_call_id;
1185 move |this, _, _, cx| {
1186 this.authorize_tool_call(
1187 id,
1188 acp::ToolCallConfirmationOutcome::Allow,
1189 cx,
1190 );
1191 }
1192 })),
1193 )
1194 .child(
1195 Button::new(("reject", tool_call_id.0), "Reject")
1196 .icon(IconName::X)
1197 .icon_position(IconPosition::Start)
1198 .icon_size(IconSize::XSmall)
1199 .icon_color(Color::Error)
1200 .label_size(LabelSize::Small)
1201 .on_click(cx.listener({
1202 let id = tool_call_id;
1203 move |this, _, _, cx| {
1204 this.authorize_tool_call(
1205 id,
1206 acp::ToolCallConfirmationOutcome::Reject,
1207 cx,
1208 );
1209 }
1210 })),
1211 ),
1212 )
1213 .into_any(),
1214 ToolCallConfirmation::Fetch { description, urls } => confirmation_container
1215 .child(
1216 v_flex()
1217 .px_2()
1218 .pb_1p5()
1219 .gap_1()
1220 .children(urls.iter().map(|url| {
1221 h_flex().child(
1222 Button::new(url.clone(), url)
1223 .icon(IconName::ArrowUpRight)
1224 .icon_color(Color::Muted)
1225 .icon_size(IconSize::XSmall)
1226 .on_click({
1227 let url = url.clone();
1228 move |_, _, cx| cx.open_url(&url)
1229 }),
1230 )
1231 }))
1232 .children(description.clone().map(|description| {
1233 self.render_markdown(
1234 description,
1235 default_markdown_style(false, window, cx),
1236 )
1237 })),
1238 )
1239 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1240 .child(
1241 button_container
1242 .child(
1243 Button::new(("always_allow", tool_call_id.0), "Always Allow")
1244 .icon(IconName::CheckDouble)
1245 .icon_position(IconPosition::Start)
1246 .icon_size(IconSize::XSmall)
1247 .icon_color(Color::Success)
1248 .label_size(LabelSize::Small)
1249 .on_click(cx.listener({
1250 let id = tool_call_id;
1251 move |this, _, _, cx| {
1252 this.authorize_tool_call(
1253 id,
1254 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1255 cx,
1256 );
1257 }
1258 })),
1259 )
1260 .child(
1261 Button::new(("allow", tool_call_id.0), "Allow")
1262 .icon(IconName::Check)
1263 .icon_position(IconPosition::Start)
1264 .icon_size(IconSize::XSmall)
1265 .icon_color(Color::Success)
1266 .label_size(LabelSize::Small)
1267 .on_click(cx.listener({
1268 let id = tool_call_id;
1269 move |this, _, _, cx| {
1270 this.authorize_tool_call(
1271 id,
1272 acp::ToolCallConfirmationOutcome::Allow,
1273 cx,
1274 );
1275 }
1276 })),
1277 )
1278 .child(
1279 Button::new(("reject", tool_call_id.0), "Reject")
1280 .icon(IconName::X)
1281 .icon_position(IconPosition::Start)
1282 .icon_size(IconSize::XSmall)
1283 .icon_color(Color::Error)
1284 .label_size(LabelSize::Small)
1285 .on_click(cx.listener({
1286 let id = tool_call_id;
1287 move |this, _, _, cx| {
1288 this.authorize_tool_call(
1289 id,
1290 acp::ToolCallConfirmationOutcome::Reject,
1291 cx,
1292 );
1293 }
1294 })),
1295 ),
1296 )
1297 .into_any(),
1298 ToolCallConfirmation::Other { description } => confirmation_container
1299 .child(v_flex().px_2().pb_1p5().child(self.render_markdown(
1300 description.clone(),
1301 default_markdown_style(false, window, cx),
1302 )))
1303 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1304 .child(
1305 button_container
1306 .child(
1307 Button::new(("always_allow", tool_call_id.0), "Always Allow")
1308 .icon(IconName::CheckDouble)
1309 .icon_position(IconPosition::Start)
1310 .icon_size(IconSize::XSmall)
1311 .icon_color(Color::Success)
1312 .label_size(LabelSize::Small)
1313 .on_click(cx.listener({
1314 let id = tool_call_id;
1315 move |this, _, _, cx| {
1316 this.authorize_tool_call(
1317 id,
1318 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1319 cx,
1320 );
1321 }
1322 })),
1323 )
1324 .child(
1325 Button::new(("allow", tool_call_id.0), "Allow")
1326 .icon(IconName::Check)
1327 .icon_position(IconPosition::Start)
1328 .icon_size(IconSize::XSmall)
1329 .icon_color(Color::Success)
1330 .label_size(LabelSize::Small)
1331 .on_click(cx.listener({
1332 let id = tool_call_id;
1333 move |this, _, _, cx| {
1334 this.authorize_tool_call(
1335 id,
1336 acp::ToolCallConfirmationOutcome::Allow,
1337 cx,
1338 );
1339 }
1340 })),
1341 )
1342 .child(
1343 Button::new(("reject", tool_call_id.0), "Reject")
1344 .icon(IconName::X)
1345 .icon_position(IconPosition::Start)
1346 .icon_size(IconSize::XSmall)
1347 .icon_color(Color::Error)
1348 .label_size(LabelSize::Small)
1349 .on_click(cx.listener({
1350 let id = tool_call_id;
1351 move |this, _, _, cx| {
1352 this.authorize_tool_call(
1353 id,
1354 acp::ToolCallConfirmationOutcome::Reject,
1355 cx,
1356 );
1357 }
1358 })),
1359 ),
1360 )
1361 .into_any(),
1362 }
1363 }
1364
1365 fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>, path: &Path) -> AnyElement {
1366 v_flex()
1367 .h_full()
1368 .child(path.to_string_lossy().to_string())
1369 .child(
1370 if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) {
1371 editor.clone().into_any_element()
1372 } else {
1373 Empty.into_any()
1374 },
1375 )
1376 .into_any()
1377 }
1378
1379 fn render_gemini_logo(&self) -> AnyElement {
1380 Icon::new(IconName::AiGemini)
1381 .color(Color::Muted)
1382 .size(IconSize::XLarge)
1383 .into_any_element()
1384 }
1385
1386 fn render_error_gemini_logo(&self) -> AnyElement {
1387 let logo = Icon::new(IconName::AiGemini)
1388 .color(Color::Muted)
1389 .size(IconSize::XLarge)
1390 .into_any_element();
1391
1392 h_flex()
1393 .relative()
1394 .justify_center()
1395 .child(div().opacity(0.3).child(logo))
1396 .child(
1397 h_flex().absolute().right_1().bottom_0().child(
1398 Icon::new(IconName::XCircle)
1399 .color(Color::Error)
1400 .size(IconSize::Small),
1401 ),
1402 )
1403 .into_any_element()
1404 }
1405
1406 fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement {
1407 v_flex()
1408 .size_full()
1409 .items_center()
1410 .justify_center()
1411 .child(
1412 if loading {
1413 h_flex()
1414 .justify_center()
1415 .child(self.render_gemini_logo())
1416 .with_animation(
1417 "pulsating_icon",
1418 Animation::new(Duration::from_secs(2))
1419 .repeat()
1420 .with_easing(pulsating_between(0.4, 1.0)),
1421 |icon, delta| icon.opacity(delta),
1422 ).into_any()
1423 } else {
1424 self.render_gemini_logo().into_any_element()
1425 }
1426 )
1427 .child(
1428 h_flex()
1429 .mt_4()
1430 .mb_1()
1431 .justify_center()
1432 .child(Headline::new(if loading {
1433 "Connecting to Gemini…"
1434 } else {
1435 "Welcome to Gemini"
1436 }).size(HeadlineSize::Medium)),
1437 )
1438 .child(
1439 div()
1440 .max_w_1_2()
1441 .text_sm()
1442 .text_center()
1443 .map(|this| if loading {
1444 this.invisible()
1445 } else {
1446 this.text_color(cx.theme().colors().text_muted)
1447 })
1448 .child("Ask questions, edit files, run commands.\nBe specific for the best results.")
1449 )
1450 .into_any()
1451 }
1452
1453 fn render_pending_auth_state(&self) -> AnyElement {
1454 v_flex()
1455 .items_center()
1456 .justify_center()
1457 .child(self.render_error_gemini_logo())
1458 .child(
1459 h_flex()
1460 .mt_4()
1461 .mb_1()
1462 .justify_center()
1463 .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
1464 )
1465 .into_any()
1466 }
1467
1468 fn render_error_state(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1469 let mut container = v_flex()
1470 .items_center()
1471 .justify_center()
1472 .child(self.render_error_gemini_logo())
1473 .child(
1474 v_flex()
1475 .mt_4()
1476 .mb_2()
1477 .gap_0p5()
1478 .text_center()
1479 .items_center()
1480 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1481 .child(
1482 Label::new(e.to_string())
1483 .size(LabelSize::Small)
1484 .color(Color::Muted),
1485 ),
1486 );
1487
1488 if matches!(e, LoadError::Unsupported { .. }) {
1489 container =
1490 container.child(Button::new("upgrade", "Upgrade Gemini to Latest").on_click(
1491 cx.listener(|this, _, window, cx| {
1492 this.workspace
1493 .update(cx, |workspace, cx| {
1494 let project = workspace.project().read(cx);
1495 let cwd = project.first_project_directory(cx);
1496 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1497 let command =
1498 "npm install -g @google/gemini-cli@latest".to_string();
1499 let spawn_in_terminal = task::SpawnInTerminal {
1500 id: task::TaskId("install".to_string()),
1501 full_label: command.clone(),
1502 label: command.clone(),
1503 command: Some(command.clone()),
1504 args: Vec::new(),
1505 command_label: command.clone(),
1506 cwd,
1507 env: Default::default(),
1508 use_new_terminal: true,
1509 allow_concurrent_runs: true,
1510 reveal: Default::default(),
1511 reveal_target: Default::default(),
1512 hide: Default::default(),
1513 shell,
1514 show_summary: true,
1515 show_command: true,
1516 show_rerun: false,
1517 };
1518 workspace
1519 .spawn_in_terminal(spawn_in_terminal, window, cx)
1520 .detach();
1521 })
1522 .ok();
1523 }),
1524 ));
1525 }
1526
1527 container.into_any()
1528 }
1529
1530 fn render_message_editor(&mut self, cx: &mut Context<Self>) -> AnyElement {
1531 let settings = ThemeSettings::get_global(cx);
1532 let font_size = TextSize::Small
1533 .rems(cx)
1534 .to_pixels(settings.agent_font_size(cx));
1535 let line_height = settings.buffer_line_height.value() * font_size;
1536
1537 let text_style = TextStyle {
1538 color: cx.theme().colors().text,
1539 font_family: settings.buffer_font.family.clone(),
1540 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1541 font_features: settings.buffer_font.features.clone(),
1542 font_size: font_size.into(),
1543 line_height: line_height.into(),
1544 ..Default::default()
1545 };
1546
1547 EditorElement::new(
1548 &self.message_editor,
1549 EditorStyle {
1550 background: cx.theme().colors().editor_background,
1551 local_player: cx.theme().players().local(),
1552 text: text_style,
1553 syntax: cx.theme().syntax().clone(),
1554 ..Default::default()
1555 },
1556 )
1557 .into_any()
1558 }
1559
1560 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
1561 let workspace = self.workspace.clone();
1562 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
1563 Self::open_link(text, &workspace, window, cx);
1564 })
1565 }
1566
1567 fn open_link(
1568 url: SharedString,
1569 workspace: &WeakEntity<Workspace>,
1570 window: &mut Window,
1571 cx: &mut App,
1572 ) {
1573 let Some(workspace) = workspace.upgrade() else {
1574 cx.open_url(&url);
1575 return;
1576 };
1577
1578 if let Some(mention_path) = MentionPath::try_parse(&url) {
1579 workspace.update(cx, |workspace, cx| {
1580 let project = workspace.project();
1581 let Some((path, entry)) = project.update(cx, |project, cx| {
1582 let path = project.find_project_path(mention_path.path(), cx)?;
1583 let entry = project.entry_for_path(&path, cx)?;
1584 Some((path, entry))
1585 }) else {
1586 return;
1587 };
1588
1589 if entry.is_dir() {
1590 project.update(cx, |_, cx| {
1591 cx.emit(project::Event::RevealInProjectPanel(entry.id));
1592 });
1593 } else {
1594 workspace
1595 .open_path(path, None, true, window, cx)
1596 .detach_and_log_err(cx);
1597 }
1598 })
1599 } else {
1600 cx.open_url(&url);
1601 }
1602 }
1603
1604 pub fn open_thread_as_markdown(
1605 &self,
1606 workspace: Entity<Workspace>,
1607 window: &mut Window,
1608 cx: &mut App,
1609 ) -> Task<anyhow::Result<()>> {
1610 let markdown_language_task = workspace
1611 .read(cx)
1612 .app_state()
1613 .languages
1614 .language_for_name("Markdown");
1615
1616 let (thread_summary, markdown) = match &self.thread_state {
1617 ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
1618 let thread = thread.read(cx);
1619 (thread.title().to_string(), thread.to_markdown(cx))
1620 }
1621 ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())),
1622 };
1623
1624 window.spawn(cx, async move |cx| {
1625 let markdown_language = markdown_language_task.await?;
1626
1627 workspace.update_in(cx, |workspace, window, cx| {
1628 let project = workspace.project().clone();
1629
1630 if !project.read(cx).is_local() {
1631 anyhow::bail!("failed to open active thread as markdown in remote project");
1632 }
1633
1634 let buffer = project.update(cx, |project, cx| {
1635 project.create_local_buffer(&markdown, Some(markdown_language), cx)
1636 });
1637 let buffer = cx.new(|cx| {
1638 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
1639 });
1640
1641 workspace.add_item_to_active_pane(
1642 Box::new(cx.new(|cx| {
1643 let mut editor =
1644 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
1645 editor.set_breadcrumb_header(thread_summary);
1646 editor
1647 })),
1648 None,
1649 true,
1650 window,
1651 cx,
1652 );
1653
1654 anyhow::Ok(())
1655 })??;
1656 anyhow::Ok(())
1657 })
1658 }
1659
1660 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
1661 self.list_state.scroll_to(ListOffset::default());
1662 cx.notify();
1663 }
1664}
1665
1666impl Focusable for AcpThreadView {
1667 fn focus_handle(&self, cx: &App) -> FocusHandle {
1668 self.message_editor.focus_handle(cx)
1669 }
1670}
1671
1672impl Render for AcpThreadView {
1673 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1674 let text = self.message_editor.read(cx).text(cx);
1675 let is_editor_empty = text.is_empty();
1676 let focus_handle = self.message_editor.focus_handle(cx);
1677
1678 let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText)
1679 .icon_size(IconSize::XSmall)
1680 .icon_color(Color::Ignored)
1681 .tooltip(Tooltip::text("Open Thread as Markdown"))
1682 .on_click(cx.listener(move |this, _, window, cx| {
1683 if let Some(workspace) = this.workspace.upgrade() {
1684 this.open_thread_as_markdown(workspace, window, cx)
1685 .detach_and_log_err(cx);
1686 }
1687 }));
1688
1689 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt)
1690 .icon_size(IconSize::XSmall)
1691 .icon_color(Color::Ignored)
1692 .tooltip(Tooltip::text("Scroll To Top"))
1693 .on_click(cx.listener(move |this, _, _, cx| {
1694 this.scroll_to_top(cx);
1695 }));
1696
1697 v_flex()
1698 .size_full()
1699 .key_context("AcpThread")
1700 .on_action(cx.listener(Self::chat))
1701 .on_action(cx.listener(Self::previous_history_message))
1702 .on_action(cx.listener(Self::next_history_message))
1703 .child(match &self.thread_state {
1704 ThreadState::Unauthenticated { .. } => v_flex()
1705 .p_2()
1706 .flex_1()
1707 .items_center()
1708 .justify_center()
1709 .child(self.render_pending_auth_state())
1710 .child(h_flex().mt_1p5().justify_center().child(
1711 Button::new("sign-in", "Sign in to Gemini").on_click(
1712 cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
1713 ),
1714 )),
1715 ThreadState::Loading { .. } => {
1716 v_flex().flex_1().child(self.render_empty_state(true, cx))
1717 }
1718 ThreadState::LoadError(e) => v_flex()
1719 .p_2()
1720 .flex_1()
1721 .items_center()
1722 .justify_center()
1723 .child(self.render_error_state(e, cx)),
1724 ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| {
1725 if self.list_state.item_count() > 0 {
1726 this.child(
1727 list(self.list_state.clone())
1728 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
1729 .flex_grow()
1730 .into_any(),
1731 )
1732 .child(
1733 h_flex()
1734 .group("controls")
1735 .mt_1()
1736 .mr_1()
1737 .py_2()
1738 .px(RESPONSE_PADDING_X)
1739 .opacity(0.4)
1740 .hover(|style| style.opacity(1.))
1741 .gap_1()
1742 .flex_wrap()
1743 .justify_end()
1744 .child(open_as_markdown)
1745 .child(scroll_to_top)
1746 .into_any_element(),
1747 )
1748 .children(match thread.read(cx).status() {
1749 ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None,
1750 ThreadStatus::Generating => div()
1751 .px_5()
1752 .py_2()
1753 .child(LoadingLabel::new("").size(LabelSize::Small))
1754 .into(),
1755 })
1756 } else {
1757 this.child(self.render_empty_state(false, cx))
1758 }
1759 }),
1760 })
1761 .when_some(self.last_error.clone(), |el, error| {
1762 el.child(
1763 div()
1764 .p_2()
1765 .text_xs()
1766 .border_t_1()
1767 .border_color(cx.theme().colors().border)
1768 .bg(cx.theme().status().error_background)
1769 .child(
1770 self.render_markdown(error, default_markdown_style(false, window, cx)),
1771 ),
1772 )
1773 })
1774 .child(
1775 v_flex()
1776 .p_2()
1777 .pt_3()
1778 .gap_1()
1779 .bg(cx.theme().colors().editor_background)
1780 .border_t_1()
1781 .border_color(cx.theme().colors().border)
1782 .child(self.render_message_editor(cx))
1783 .child({
1784 let thread = self.thread();
1785
1786 h_flex().justify_end().child(
1787 if thread.map_or(true, |thread| {
1788 thread.read(cx).status() == ThreadStatus::Idle
1789 }) {
1790 IconButton::new("send-message", IconName::Send)
1791 .icon_color(Color::Accent)
1792 .style(ButtonStyle::Filled)
1793 .disabled(thread.is_none() || is_editor_empty)
1794 .on_click({
1795 let focus_handle = focus_handle.clone();
1796 move |_event, window, cx| {
1797 focus_handle.dispatch_action(&Chat, window, cx);
1798 }
1799 })
1800 .when(!is_editor_empty, |button| {
1801 button.tooltip(move |window, cx| {
1802 Tooltip::for_action("Send", &Chat, window, cx)
1803 })
1804 })
1805 .when(is_editor_empty, |button| {
1806 button.tooltip(Tooltip::text("Type a message to submit"))
1807 })
1808 } else {
1809 IconButton::new("stop-generation", IconName::StopFilled)
1810 .icon_color(Color::Error)
1811 .style(ButtonStyle::Tinted(ui::TintColor::Error))
1812 .tooltip(move |window, cx| {
1813 Tooltip::for_action(
1814 "Stop Generation",
1815 &editor::actions::Cancel,
1816 window,
1817 cx,
1818 )
1819 })
1820 .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
1821 },
1822 )
1823 }),
1824 )
1825 }
1826}
1827
1828fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1829 let mut style = default_markdown_style(false, window, cx);
1830 let mut text_style = window.text_style();
1831 let theme_settings = ThemeSettings::get_global(cx);
1832
1833 let buffer_font = theme_settings.buffer_font.family.clone();
1834 let buffer_font_size = TextSize::Small.rems(cx);
1835
1836 text_style.refine(&TextStyleRefinement {
1837 font_family: Some(buffer_font),
1838 font_size: Some(buffer_font_size.into()),
1839 ..Default::default()
1840 });
1841
1842 style.base_text_style = text_style;
1843 style.link_callback = Some(Rc::new(move |url, cx| {
1844 if MentionPath::try_parse(url).is_some() {
1845 let colors = cx.theme().colors();
1846 Some(TextStyleRefinement {
1847 background_color: Some(colors.element_background),
1848 ..Default::default()
1849 })
1850 } else {
1851 None
1852 }
1853 }));
1854 style
1855}
1856
1857fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
1858 let theme_settings = ThemeSettings::get_global(cx);
1859 let colors = cx.theme().colors();
1860
1861 let buffer_font_size = TextSize::Small.rems(cx);
1862
1863 let mut text_style = window.text_style();
1864 let line_height = buffer_font_size * 1.75;
1865
1866 let font_family = if buffer_font {
1867 theme_settings.buffer_font.family.clone()
1868 } else {
1869 theme_settings.ui_font.family.clone()
1870 };
1871
1872 let font_size = if buffer_font {
1873 TextSize::Small.rems(cx)
1874 } else {
1875 TextSize::Default.rems(cx)
1876 };
1877
1878 text_style.refine(&TextStyleRefinement {
1879 font_family: Some(font_family),
1880 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1881 font_features: Some(theme_settings.ui_font.features.clone()),
1882 font_size: Some(font_size.into()),
1883 line_height: Some(line_height.into()),
1884 color: Some(cx.theme().colors().text),
1885 ..Default::default()
1886 });
1887
1888 MarkdownStyle {
1889 base_text_style: text_style.clone(),
1890 syntax: cx.theme().syntax().clone(),
1891 selection_background_color: cx.theme().colors().element_selection_background,
1892 code_block_overflow_x_scroll: true,
1893 table_overflow_x_scroll: true,
1894 heading_level_styles: Some(HeadingLevelStyles {
1895 h1: Some(TextStyleRefinement {
1896 font_size: Some(rems(1.15).into()),
1897 ..Default::default()
1898 }),
1899 h2: Some(TextStyleRefinement {
1900 font_size: Some(rems(1.1).into()),
1901 ..Default::default()
1902 }),
1903 h3: Some(TextStyleRefinement {
1904 font_size: Some(rems(1.05).into()),
1905 ..Default::default()
1906 }),
1907 h4: Some(TextStyleRefinement {
1908 font_size: Some(rems(1.).into()),
1909 ..Default::default()
1910 }),
1911 h5: Some(TextStyleRefinement {
1912 font_size: Some(rems(0.95).into()),
1913 ..Default::default()
1914 }),
1915 h6: Some(TextStyleRefinement {
1916 font_size: Some(rems(0.875).into()),
1917 ..Default::default()
1918 }),
1919 }),
1920 code_block: StyleRefinement {
1921 padding: EdgesRefinement {
1922 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1923 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1924 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1925 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1926 },
1927 margin: EdgesRefinement {
1928 top: Some(Length::Definite(Pixels(8.).into())),
1929 left: Some(Length::Definite(Pixels(0.).into())),
1930 right: Some(Length::Definite(Pixels(0.).into())),
1931 bottom: Some(Length::Definite(Pixels(12.).into())),
1932 },
1933 border_style: Some(BorderStyle::Solid),
1934 border_widths: EdgesRefinement {
1935 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
1936 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
1937 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
1938 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
1939 },
1940 border_color: Some(colors.border_variant),
1941 background: Some(colors.editor_background.into()),
1942 text: Some(TextStyleRefinement {
1943 font_family: Some(theme_settings.buffer_font.family.clone()),
1944 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1945 font_features: Some(theme_settings.buffer_font.features.clone()),
1946 font_size: Some(buffer_font_size.into()),
1947 ..Default::default()
1948 }),
1949 ..Default::default()
1950 },
1951 inline_code: TextStyleRefinement {
1952 font_family: Some(theme_settings.buffer_font.family.clone()),
1953 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1954 font_features: Some(theme_settings.buffer_font.features.clone()),
1955 font_size: Some(buffer_font_size.into()),
1956 background_color: Some(colors.editor_foreground.opacity(0.08)),
1957 ..Default::default()
1958 },
1959 link: TextStyleRefinement {
1960 background_color: Some(colors.editor_foreground.opacity(0.025)),
1961 underline: Some(UnderlineStyle {
1962 color: Some(colors.text_accent.opacity(0.5)),
1963 thickness: px(1.),
1964 ..Default::default()
1965 }),
1966 ..Default::default()
1967 },
1968 ..Default::default()
1969 }
1970}