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