1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::path::Path;
4use std::rc::Rc;
5use std::sync::Arc;
6use std::time::Duration;
7
8use agentic_coding_protocol::{self as acp};
9use assistant_tool::ActionLog;
10use buffer_diff::BufferDiff;
11use collections::{HashMap, HashSet};
12use editor::{
13 AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
14 EditorStyle, MinimapVisibility, MultiBuffer, PathKey,
15};
16use file_icons::FileIcons;
17use futures::channel::oneshot;
18use gpui::{
19 Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId,
20 FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement,
21 Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
22 Window, div, linear_color_stop, linear_gradient, list, percentage, point, prelude::*,
23 pulsating_between,
24};
25use language::language_settings::SoftWrap;
26use language::{Buffer, Language};
27use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
28use parking_lot::Mutex;
29use project::Project;
30use settings::Settings as _;
31use text::Anchor;
32use theme::ThemeSettings;
33use ui::{Disclosure, Divider, DividerColor, KeyBinding, Tooltip, prelude::*};
34use util::ResultExt;
35use workspace::{CollaboratorId, Workspace};
36use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
37
38use ::acp::{
39 AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff,
40 LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent,
41 ToolCallId, ToolCallStatus,
42};
43
44use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
45use crate::acp::message_history::MessageHistory;
46use crate::agent_diff::AgentDiff;
47use crate::{AgentDiffPane, Follow, KeepAll, OpenAgentDiff, RejectAll};
48
49const RESPONSE_PADDING_X: Pixels = px(19.);
50
51pub struct AcpThreadView {
52 workspace: WeakEntity<Workspace>,
53 project: Entity<Project>,
54 thread_state: ThreadState,
55 diff_editors: HashMap<EntityId, Entity<Editor>>,
56 message_editor: Entity<Editor>,
57 message_set_from_history: bool,
58 _message_editor_subscription: Subscription,
59 mention_set: Arc<Mutex<MentionSet>>,
60 last_error: Option<Entity<Markdown>>,
61 list_state: ListState,
62 auth_task: Option<Task<()>>,
63 expanded_tool_calls: HashSet<ToolCallId>,
64 expanded_thinking_blocks: HashSet<(usize, usize)>,
65 edits_expanded: bool,
66 message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
67}
68
69enum ThreadState {
70 Loading {
71 _task: Task<()>,
72 },
73 Ready {
74 thread: Entity<AcpThread>,
75 _subscription: [Subscription; 2],
76 },
77 LoadError(LoadError),
78 Unauthenticated {
79 thread: Entity<AcpThread>,
80 },
81}
82
83impl AcpThreadView {
84 pub fn new(
85 workspace: WeakEntity<Workspace>,
86 project: Entity<Project>,
87 message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
88 window: &mut Window,
89 cx: &mut Context<Self>,
90 ) -> Self {
91 let language = Language::new(
92 language::LanguageConfig {
93 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
94 ..Default::default()
95 },
96 None,
97 );
98
99 let mention_set = Arc::new(Mutex::new(MentionSet::default()));
100
101 let message_editor = cx.new(|cx| {
102 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
103 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
104
105 let mut editor = Editor::new(
106 editor::EditorMode::AutoHeight {
107 min_lines: 4,
108 max_lines: None,
109 },
110 buffer,
111 None,
112 window,
113 cx,
114 );
115 editor.set_placeholder_text("Message the agent - @ to include files", cx);
116 editor.set_show_indent_guides(false, cx);
117 editor.set_soft_wrap();
118 editor.set_use_modal_editing(true);
119 editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
120 mention_set.clone(),
121 workspace.clone(),
122 cx.weak_entity(),
123 ))));
124 editor.set_context_menu_options(ContextMenuOptions {
125 min_entries_visible: 12,
126 max_entries_visible: 12,
127 placement: Some(ContextMenuPlacement::Above),
128 });
129 editor
130 });
131
132 let message_editor_subscription = cx.subscribe(&message_editor, |this, _, event, _| {
133 if let editor::EditorEvent::BufferEdited = &event {
134 if !this.message_set_from_history {
135 this.message_history.borrow_mut().reset_position();
136 }
137 this.message_set_from_history = false;
138 }
139 });
140
141 let mention_set = mention_set.clone();
142
143 let list_state = ListState::new(
144 0,
145 gpui::ListAlignment::Bottom,
146 px(2048.0),
147 cx.processor({
148 move |this: &mut Self, index: usize, window, cx| {
149 let Some((entry, len)) = this.thread().and_then(|thread| {
150 let entries = &thread.read(cx).entries();
151 Some((entries.get(index)?, entries.len()))
152 }) else {
153 return Empty.into_any();
154 };
155 this.render_entry(index, len, entry, window, cx)
156 }
157 }),
158 );
159
160 Self {
161 workspace: workspace.clone(),
162 project: project.clone(),
163 thread_state: Self::initial_state(workspace, project, window, cx),
164 message_editor,
165 message_set_from_history: false,
166 _message_editor_subscription: message_editor_subscription,
167 mention_set,
168 diff_editors: Default::default(),
169 list_state: list_state,
170 last_error: None,
171 auth_task: None,
172 expanded_tool_calls: HashSet::default(),
173 expanded_thinking_blocks: HashSet::default(),
174 edits_expanded: false,
175 message_history,
176 }
177 }
178
179 fn initial_state(
180 workspace: WeakEntity<Workspace>,
181 project: Entity<Project>,
182 window: &mut Window,
183 cx: &mut Context<Self>,
184 ) -> ThreadState {
185 let root_dir = project
186 .read(cx)
187 .visible_worktrees(cx)
188 .next()
189 .map(|worktree| worktree.read(cx).abs_path())
190 .unwrap_or_else(|| paths::home_dir().as_path().into());
191
192 let load_task = cx.spawn_in(window, async move |this, cx| {
193 let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await
194 {
195 Ok(thread) => thread,
196 Err(err) => {
197 this.update(cx, |this, cx| {
198 this.handle_load_error(err, cx);
199 cx.notify();
200 })
201 .log_err();
202 return;
203 }
204 };
205
206 let init_response = async {
207 let resp = thread
208 .read_with(cx, |thread, _cx| thread.initialize())?
209 .await?;
210 anyhow::Ok(resp)
211 };
212
213 let result = match init_response.await {
214 Err(e) => {
215 let mut cx = cx.clone();
216 if e.downcast_ref::<oneshot::Canceled>().is_some() {
217 let child_status = thread
218 .update(&mut cx, |thread, _| thread.child_status())
219 .ok()
220 .flatten();
221 if let Some(child_status) = child_status {
222 match child_status.await {
223 Ok(_) => Err(e),
224 Err(e) => Err(e),
225 }
226 } else {
227 Err(e)
228 }
229 } else {
230 Err(e)
231 }
232 }
233 Ok(response) => {
234 if !response.is_authenticated {
235 this.update(cx, |this, _| {
236 this.thread_state = ThreadState::Unauthenticated { thread };
237 })
238 .ok();
239 return;
240 };
241 Ok(())
242 }
243 };
244
245 this.update_in(cx, |this, window, cx| {
246 match result {
247 Ok(()) => {
248 let thread_subscription =
249 cx.subscribe_in(&thread, window, Self::handle_thread_event);
250
251 let action_log = thread.read(cx).action_log().clone();
252 let action_log_subscription =
253 cx.observe(&action_log, |_, _, cx| cx.notify());
254
255 this.list_state
256 .splice(0..0, thread.read(cx).entries().len());
257
258 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
259
260 this.thread_state = ThreadState::Ready {
261 thread,
262 _subscription: [thread_subscription, action_log_subscription],
263 };
264
265 cx.notify();
266 }
267 Err(err) => {
268 this.handle_load_error(err, cx);
269 }
270 };
271 })
272 .log_err();
273 });
274
275 ThreadState::Loading { _task: load_task }
276 }
277
278 fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context<Self>) {
279 if let Some(load_err) = err.downcast_ref::<LoadError>() {
280 self.thread_state = ThreadState::LoadError(load_err.clone());
281 } else {
282 self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
283 }
284 cx.notify();
285 }
286
287 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
288 match &self.thread_state {
289 ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
290 Some(thread)
291 }
292 ThreadState::Loading { .. } | ThreadState::LoadError(..) => None,
293 }
294 }
295
296 pub fn title(&self, cx: &App) -> SharedString {
297 match &self.thread_state {
298 ThreadState::Ready { thread, .. } => thread.read(cx).title(),
299 ThreadState::Loading { .. } => "Loading…".into(),
300 ThreadState::LoadError(_) => "Failed to load".into(),
301 ThreadState::Unauthenticated { .. } => "Not authenticated".into(),
302 }
303 }
304
305 pub fn cancel(&mut self, cx: &mut Context<Self>) {
306 self.last_error.take();
307
308 if let Some(thread) = self.thread() {
309 thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
310 }
311 }
312
313 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
314 self.last_error.take();
315
316 let mut ix = 0;
317 let mut chunks: Vec<acp::UserMessageChunk> = Vec::new();
318 let project = self.project.clone();
319 self.message_editor.update(cx, |editor, cx| {
320 let text = editor.text(cx);
321 editor.display_map.update(cx, |map, cx| {
322 let snapshot = map.snapshot(cx);
323 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
324 if let Some(project_path) =
325 self.mention_set.lock().path_for_crease_id(crease_id)
326 {
327 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
328 if crease_range.start > ix {
329 chunks.push(acp::UserMessageChunk::Text {
330 text: text[ix..crease_range.start].to_string(),
331 });
332 }
333 if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) {
334 chunks.push(acp::UserMessageChunk::Path { path: abs_path });
335 }
336 ix = crease_range.end;
337 }
338 }
339
340 if ix < text.len() {
341 let last_chunk = text[ix..].trim();
342 if !last_chunk.is_empty() {
343 chunks.push(acp::UserMessageChunk::Text {
344 text: last_chunk.into(),
345 });
346 }
347 }
348 })
349 });
350
351 if chunks.is_empty() {
352 return;
353 }
354
355 let Some(thread) = self.thread() else { return };
356 let message = acp::SendUserMessageParams { chunks };
357 let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx));
358
359 cx.spawn(async move |this, cx| {
360 let result = task.await;
361
362 this.update(cx, |this, cx| {
363 if let Err(err) = result {
364 this.last_error =
365 Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx)))
366 }
367 })
368 })
369 .detach();
370
371 let mention_set = self.mention_set.clone();
372
373 self.message_editor.update(cx, |editor, cx| {
374 editor.clear(window, cx);
375 editor.remove_creases(mention_set.lock().drain(), cx)
376 });
377
378 self.message_history.borrow_mut().push(message);
379 }
380
381 fn previous_history_message(
382 &mut self,
383 _: &PreviousHistoryMessage,
384 window: &mut Window,
385 cx: &mut Context<Self>,
386 ) {
387 self.message_set_from_history = Self::set_draft_message(
388 self.message_editor.clone(),
389 self.mention_set.clone(),
390 self.project.clone(),
391 self.message_history.borrow_mut().prev(),
392 window,
393 cx,
394 );
395 }
396
397 fn next_history_message(
398 &mut self,
399 _: &NextHistoryMessage,
400 window: &mut Window,
401 cx: &mut Context<Self>,
402 ) {
403 self.message_set_from_history = Self::set_draft_message(
404 self.message_editor.clone(),
405 self.mention_set.clone(),
406 self.project.clone(),
407 self.message_history.borrow_mut().next(),
408 window,
409 cx,
410 );
411 }
412
413 fn set_draft_message(
414 message_editor: Entity<Editor>,
415 mention_set: Arc<Mutex<MentionSet>>,
416 project: Entity<Project>,
417 message: Option<&acp::SendUserMessageParams>,
418 window: &mut Window,
419 cx: &mut Context<Self>,
420 ) -> bool {
421 cx.notify();
422
423 let Some(message) = message else {
424 return false;
425 };
426
427 let mut text = String::new();
428 let mut mentions = Vec::new();
429
430 for chunk in &message.chunks {
431 match chunk {
432 acp::UserMessageChunk::Text { text: chunk } => {
433 text.push_str(&chunk);
434 }
435 acp::UserMessageChunk::Path { path } => {
436 let start = text.len();
437 let content = MentionPath::new(path).to_string();
438 text.push_str(&content);
439 let end = text.len();
440 if let Some(project_path) =
441 project.read(cx).project_path_for_absolute_path(path, cx)
442 {
443 let filename: SharedString = path
444 .file_name()
445 .unwrap_or_default()
446 .to_string_lossy()
447 .to_string()
448 .into();
449 mentions.push((start..end, project_path, filename));
450 }
451 }
452 }
453 }
454
455 let snapshot = message_editor.update(cx, |editor, cx| {
456 editor.set_text(text, window, cx);
457 editor.buffer().read(cx).snapshot(cx)
458 });
459
460 for (range, project_path, filename) in mentions {
461 let crease_icon_path = if project_path.path.is_dir() {
462 FileIcons::get_folder_icon(false, cx)
463 .unwrap_or_else(|| IconName::Folder.path().into())
464 } else {
465 FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
466 .unwrap_or_else(|| IconName::File.path().into())
467 };
468
469 let anchor = snapshot.anchor_before(range.start);
470 let crease_id = crate::context_picker::insert_crease_for_mention(
471 anchor.excerpt_id,
472 anchor.text_anchor,
473 range.end - range.start,
474 filename,
475 crease_icon_path,
476 message_editor.clone(),
477 window,
478 cx,
479 );
480 if let Some(crease_id) = crease_id {
481 mention_set.lock().insert(crease_id, project_path);
482 }
483 }
484
485 true
486 }
487
488 fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
489 if let Some(thread) = self.thread() {
490 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
491 }
492 }
493
494 fn open_edited_buffer(
495 &mut self,
496 buffer: &Entity<Buffer>,
497 window: &mut Window,
498 cx: &mut Context<Self>,
499 ) {
500 let Some(thread) = self.thread() else {
501 return;
502 };
503
504 let Some(diff) =
505 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
506 else {
507 return;
508 };
509
510 diff.update(cx, |diff, cx| {
511 diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx)
512 })
513 }
514
515 fn handle_thread_event(
516 &mut self,
517 thread: &Entity<AcpThread>,
518 event: &AcpThreadEvent,
519 window: &mut Window,
520 cx: &mut Context<Self>,
521 ) {
522 let count = self.list_state.item_count();
523 match event {
524 AcpThreadEvent::NewEntry => {
525 let index = thread.read(cx).entries().len() - 1;
526 self.sync_thread_entry_view(index, window, cx);
527 self.list_state.splice(count..count, 1);
528 }
529 AcpThreadEvent::EntryUpdated(index) => {
530 let index = *index;
531 self.sync_thread_entry_view(index, window, cx);
532 self.list_state.splice(index..index + 1, 1);
533 }
534 }
535 cx.notify();
536 }
537
538 fn sync_thread_entry_view(
539 &mut self,
540 entry_ix: usize,
541 window: &mut Window,
542 cx: &mut Context<Self>,
543 ) {
544 let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else {
545 return;
546 };
547
548 if self.diff_editors.contains_key(&multibuffer.entity_id()) {
549 return;
550 }
551
552 let editor = cx.new(|cx| {
553 let mut editor = Editor::new(
554 EditorMode::Full {
555 scale_ui_elements_with_buffer_font_size: false,
556 show_active_line_background: false,
557 sized_by_content: true,
558 },
559 multibuffer.clone(),
560 None,
561 window,
562 cx,
563 );
564 editor.set_show_gutter(false, cx);
565 editor.disable_inline_diagnostics();
566 editor.disable_expand_excerpt_buttons(cx);
567 editor.set_show_vertical_scrollbar(false, cx);
568 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
569 editor.set_soft_wrap_mode(SoftWrap::None, cx);
570 editor.scroll_manager.set_forbid_vertical_scroll(true);
571 editor.set_show_indent_guides(false, cx);
572 editor.set_read_only(true);
573 editor.set_show_breakpoints(false, cx);
574 editor.set_show_code_actions(false, cx);
575 editor.set_show_git_diff_gutter(false, cx);
576 editor.set_expand_all_diff_hunks(cx);
577 editor.set_text_style_refinement(TextStyleRefinement {
578 font_size: Some(
579 TextSize::Small
580 .rems(cx)
581 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
582 .into(),
583 ),
584 ..Default::default()
585 });
586 editor
587 });
588 let entity_id = multibuffer.entity_id();
589 cx.observe_release(&multibuffer, move |this, _, _| {
590 this.diff_editors.remove(&entity_id);
591 })
592 .detach();
593
594 self.diff_editors.insert(entity_id, editor);
595 }
596
597 fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
598 let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
599 entry.diff().map(|diff| diff.multibuffer.clone())
600 }
601
602 fn authenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
603 let Some(thread) = self.thread().cloned() else {
604 return;
605 };
606
607 self.last_error.take();
608 let authenticate = thread.read(cx).authenticate();
609 self.auth_task = Some(cx.spawn_in(window, {
610 let project = self.project.clone();
611 async move |this, cx| {
612 let result = authenticate.await;
613
614 this.update_in(cx, |this, window, cx| {
615 if let Err(err) = result {
616 this.last_error = Some(cx.new(|cx| {
617 Markdown::new(format!("Error: {err}").into(), None, None, cx)
618 }))
619 } else {
620 this.thread_state =
621 Self::initial_state(this.workspace.clone(), project.clone(), window, cx)
622 }
623 this.auth_task.take()
624 })
625 .ok();
626 }
627 }));
628 }
629
630 fn authorize_tool_call(
631 &mut self,
632 id: ToolCallId,
633 outcome: acp::ToolCallConfirmationOutcome,
634 cx: &mut Context<Self>,
635 ) {
636 let Some(thread) = self.thread() else {
637 return;
638 };
639 thread.update(cx, |thread, cx| {
640 thread.authorize_tool_call(id, outcome, cx);
641 });
642 cx.notify();
643 }
644
645 fn render_entry(
646 &self,
647 index: usize,
648 total_entries: usize,
649 entry: &AgentThreadEntry,
650 window: &mut Window,
651 cx: &Context<Self>,
652 ) -> AnyElement {
653 match &entry {
654 AgentThreadEntry::UserMessage(message) => div()
655 .py_4()
656 .px_2()
657 .child(
658 v_flex()
659 .p_3()
660 .gap_1p5()
661 .rounded_lg()
662 .shadow_md()
663 .bg(cx.theme().colors().editor_background)
664 .border_1()
665 .border_color(cx.theme().colors().border)
666 .text_xs()
667 .child(self.render_markdown(
668 message.content.clone(),
669 user_message_markdown_style(window, cx),
670 )),
671 )
672 .into_any(),
673 AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
674 let style = default_markdown_style(false, window, cx);
675 let message_body = v_flex()
676 .w_full()
677 .gap_2p5()
678 .children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| {
679 match chunk {
680 AssistantMessageChunk::Text { chunk } => self
681 .render_markdown(chunk.clone(), style.clone())
682 .into_any_element(),
683 AssistantMessageChunk::Thought { chunk } => self.render_thinking_block(
684 index,
685 chunk_ix,
686 chunk.clone(),
687 window,
688 cx,
689 ),
690 }
691 }))
692 .into_any();
693
694 v_flex()
695 .px_5()
696 .py_1()
697 .when(index + 1 == total_entries, |this| this.pb_4())
698 .w_full()
699 .text_ui(cx)
700 .child(message_body)
701 .into_any()
702 }
703 AgentThreadEntry::ToolCall(tool_call) => div()
704 .py_1p5()
705 .px_5()
706 .child(self.render_tool_call(index, tool_call, window, cx))
707 .into_any(),
708 }
709 }
710
711 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
712 cx.theme()
713 .colors()
714 .element_background
715 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
716 }
717
718 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
719 cx.theme().colors().border.opacity(0.6)
720 }
721
722 fn tool_name_font_size(&self) -> Rems {
723 rems_from_px(13.)
724 }
725
726 fn render_thinking_block(
727 &self,
728 entry_ix: usize,
729 chunk_ix: usize,
730 chunk: Entity<Markdown>,
731 window: &Window,
732 cx: &Context<Self>,
733 ) -> AnyElement {
734 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
735 let key = (entry_ix, chunk_ix);
736 let is_open = self.expanded_thinking_blocks.contains(&key);
737
738 v_flex()
739 .child(
740 h_flex()
741 .id(header_id)
742 .group("disclosure-header")
743 .w_full()
744 .justify_between()
745 .opacity(0.8)
746 .hover(|style| style.opacity(1.))
747 .child(
748 h_flex()
749 .gap_1p5()
750 .child(
751 Icon::new(IconName::ToolBulb)
752 .size(IconSize::Small)
753 .color(Color::Muted),
754 )
755 .child(
756 div()
757 .text_size(self.tool_name_font_size())
758 .child("Thinking"),
759 ),
760 )
761 .child(
762 div().visible_on_hover("disclosure-header").child(
763 Disclosure::new("thinking-disclosure", is_open)
764 .opened_icon(IconName::ChevronUp)
765 .closed_icon(IconName::ChevronDown)
766 .on_click(cx.listener({
767 move |this, _event, _window, cx| {
768 if is_open {
769 this.expanded_thinking_blocks.remove(&key);
770 } else {
771 this.expanded_thinking_blocks.insert(key);
772 }
773 cx.notify();
774 }
775 })),
776 ),
777 )
778 .on_click(cx.listener({
779 move |this, _event, _window, cx| {
780 if is_open {
781 this.expanded_thinking_blocks.remove(&key);
782 } else {
783 this.expanded_thinking_blocks.insert(key);
784 }
785 cx.notify();
786 }
787 })),
788 )
789 .when(is_open, |this| {
790 this.child(
791 div()
792 .relative()
793 .mt_1p5()
794 .ml(px(7.))
795 .pl_4()
796 .border_l_1()
797 .border_color(self.tool_card_border_color(cx))
798 .text_ui_sm(cx)
799 .child(
800 self.render_markdown(chunk, default_markdown_style(false, window, cx)),
801 ),
802 )
803 })
804 .into_any_element()
805 }
806
807 fn render_tool_call(
808 &self,
809 entry_ix: usize,
810 tool_call: &ToolCall,
811 window: &Window,
812 cx: &Context<Self>,
813 ) -> Div {
814 let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix));
815
816 let status_icon = match &tool_call.status {
817 ToolCallStatus::WaitingForConfirmation { .. } => None,
818 ToolCallStatus::Allowed {
819 status: acp::ToolCallStatus::Running,
820 ..
821 } => Some(
822 Icon::new(IconName::ArrowCircle)
823 .color(Color::Accent)
824 .size(IconSize::Small)
825 .with_animation(
826 "running",
827 Animation::new(Duration::from_secs(2)).repeat(),
828 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
829 )
830 .into_any(),
831 ),
832 ToolCallStatus::Allowed {
833 status: acp::ToolCallStatus::Finished,
834 ..
835 } => None,
836 ToolCallStatus::Rejected
837 | ToolCallStatus::Canceled
838 | ToolCallStatus::Allowed {
839 status: acp::ToolCallStatus::Error,
840 ..
841 } => Some(
842 Icon::new(IconName::X)
843 .color(Color::Error)
844 .size(IconSize::Small)
845 .into_any_element(),
846 ),
847 };
848
849 let needs_confirmation = match &tool_call.status {
850 ToolCallStatus::WaitingForConfirmation { .. } => true,
851 _ => tool_call
852 .content
853 .iter()
854 .any(|content| matches!(content, ToolCallContent::Diff { .. })),
855 };
856
857 let is_collapsible = tool_call.content.is_some() && !needs_confirmation;
858 let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id);
859
860 let content = if is_open {
861 match &tool_call.status {
862 ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
863 Some(self.render_tool_call_confirmation(
864 tool_call.id,
865 confirmation,
866 tool_call.content.as_ref(),
867 window,
868 cx,
869 ))
870 }
871 ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
872 tool_call.content.as_ref().map(|content| {
873 div()
874 .py_1p5()
875 .child(self.render_tool_call_content(content, window, cx))
876 .into_any_element()
877 })
878 }
879 ToolCallStatus::Rejected => None,
880 }
881 } else {
882 None
883 };
884
885 v_flex()
886 .when(needs_confirmation, |this| {
887 this.rounded_lg()
888 .border_1()
889 .border_color(self.tool_card_border_color(cx))
890 .bg(cx.theme().colors().editor_background)
891 .overflow_hidden()
892 })
893 .child(
894 h_flex()
895 .id(header_id)
896 .w_full()
897 .gap_1()
898 .justify_between()
899 .map(|this| {
900 if needs_confirmation {
901 this.px_2()
902 .py_1()
903 .rounded_t_md()
904 .bg(self.tool_card_header_bg(cx))
905 .border_b_1()
906 .border_color(self.tool_card_border_color(cx))
907 } else {
908 this.opacity(0.8).hover(|style| style.opacity(1.))
909 }
910 })
911 .child(
912 h_flex()
913 .id("tool-call-header")
914 .overflow_x_scroll()
915 .map(|this| {
916 if needs_confirmation {
917 this.text_xs()
918 } else {
919 this.text_size(self.tool_name_font_size())
920 }
921 })
922 .gap_1p5()
923 .child(
924 Icon::new(tool_call.icon)
925 .size(IconSize::Small)
926 .color(Color::Muted),
927 )
928 .child(if tool_call.locations.len() == 1 {
929 let name = tool_call.locations[0]
930 .path
931 .file_name()
932 .unwrap_or_default()
933 .display()
934 .to_string();
935
936 h_flex()
937 .id(("open-tool-call-location", entry_ix))
938 .child(name)
939 .w_full()
940 .max_w_full()
941 .pr_1()
942 .gap_0p5()
943 .cursor_pointer()
944 .rounded_sm()
945 .opacity(0.8)
946 .hover(|label| {
947 label.opacity(1.).bg(cx
948 .theme()
949 .colors()
950 .element_hover
951 .opacity(0.5))
952 })
953 .tooltip(Tooltip::text("Jump to File"))
954 .on_click(cx.listener(move |this, _, window, cx| {
955 this.open_tool_call_location(entry_ix, 0, window, cx);
956 }))
957 .into_any_element()
958 } else {
959 self.render_markdown(
960 tool_call.label.clone(),
961 default_markdown_style(needs_confirmation, window, cx),
962 )
963 .into_any()
964 }),
965 )
966 .child(
967 h_flex()
968 .gap_0p5()
969 .when(is_collapsible, |this| {
970 this.child(
971 Disclosure::new(("expand", tool_call.id.0), is_open)
972 .opened_icon(IconName::ChevronUp)
973 .closed_icon(IconName::ChevronDown)
974 .on_click(cx.listener({
975 let id = tool_call.id;
976 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
977 if is_open {
978 this.expanded_tool_calls.remove(&id);
979 } else {
980 this.expanded_tool_calls.insert(id);
981 }
982 cx.notify();
983 }
984 })),
985 )
986 })
987 .children(status_icon),
988 )
989 .on_click(cx.listener({
990 let id = tool_call.id;
991 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
992 if is_open {
993 this.expanded_tool_calls.remove(&id);
994 } else {
995 this.expanded_tool_calls.insert(id);
996 }
997 cx.notify();
998 }
999 })),
1000 )
1001 .when(is_open, |this| {
1002 this.child(
1003 div()
1004 .text_xs()
1005 .when(is_collapsible, |this| {
1006 this.mt_1()
1007 .border_1()
1008 .border_color(self.tool_card_border_color(cx))
1009 .bg(cx.theme().colors().editor_background)
1010 .rounded_lg()
1011 })
1012 .children(content),
1013 )
1014 })
1015 }
1016
1017 fn render_tool_call_content(
1018 &self,
1019 content: &ToolCallContent,
1020 window: &Window,
1021 cx: &Context<Self>,
1022 ) -> AnyElement {
1023 match content {
1024 ToolCallContent::Markdown { markdown } => {
1025 div()
1026 .p_2()
1027 .child(self.render_markdown(
1028 markdown.clone(),
1029 default_markdown_style(false, window, cx),
1030 ))
1031 .into_any_element()
1032 }
1033 ToolCallContent::Diff {
1034 diff: Diff { multibuffer, .. },
1035 ..
1036 } => self.render_diff_editor(multibuffer),
1037 }
1038 }
1039
1040 fn render_tool_call_confirmation(
1041 &self,
1042 tool_call_id: ToolCallId,
1043 confirmation: &ToolCallConfirmation,
1044 content: Option<&ToolCallContent>,
1045 window: &Window,
1046 cx: &Context<Self>,
1047 ) -> AnyElement {
1048 let confirmation_container = v_flex().mt_1().py_1p5();
1049
1050 let button_container = h_flex()
1051 .pt_1p5()
1052 .px_1p5()
1053 .gap_1()
1054 .justify_end()
1055 .border_t_1()
1056 .border_color(self.tool_card_border_color(cx));
1057
1058 match confirmation {
1059 ToolCallConfirmation::Edit { description } => confirmation_container
1060 .child(
1061 div()
1062 .px_2()
1063 .children(description.clone().map(|description| {
1064 self.render_markdown(
1065 description,
1066 default_markdown_style(false, window, cx),
1067 )
1068 })),
1069 )
1070 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1071 .child(
1072 button_container
1073 .child(
1074 Button::new(("always_allow", tool_call_id.0), "Always Allow Edits")
1075 .icon(IconName::CheckDouble)
1076 .icon_position(IconPosition::Start)
1077 .icon_size(IconSize::XSmall)
1078 .icon_color(Color::Success)
1079 .on_click(cx.listener({
1080 let id = tool_call_id;
1081 move |this, _, _, cx| {
1082 this.authorize_tool_call(
1083 id,
1084 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1085 cx,
1086 );
1087 }
1088 })),
1089 )
1090 .child(
1091 Button::new(("allow", tool_call_id.0), "Allow")
1092 .icon(IconName::Check)
1093 .icon_position(IconPosition::Start)
1094 .icon_size(IconSize::XSmall)
1095 .icon_color(Color::Success)
1096 .on_click(cx.listener({
1097 let id = tool_call_id;
1098 move |this, _, _, cx| {
1099 this.authorize_tool_call(
1100 id,
1101 acp::ToolCallConfirmationOutcome::Allow,
1102 cx,
1103 );
1104 }
1105 })),
1106 )
1107 .child(
1108 Button::new(("reject", tool_call_id.0), "Reject")
1109 .icon(IconName::X)
1110 .icon_position(IconPosition::Start)
1111 .icon_size(IconSize::XSmall)
1112 .icon_color(Color::Error)
1113 .on_click(cx.listener({
1114 let id = tool_call_id;
1115 move |this, _, _, cx| {
1116 this.authorize_tool_call(
1117 id,
1118 acp::ToolCallConfirmationOutcome::Reject,
1119 cx,
1120 );
1121 }
1122 })),
1123 ),
1124 )
1125 .into_any(),
1126 ToolCallConfirmation::Execute {
1127 command,
1128 root_command,
1129 description,
1130 } => confirmation_container
1131 .child(v_flex().px_2().pb_1p5().child(command.clone()).children(
1132 description.clone().map(|description| {
1133 self.render_markdown(description, default_markdown_style(false, window, cx))
1134 .on_url_click({
1135 let workspace = self.workspace.clone();
1136 move |text, window, cx| {
1137 Self::open_link(text, &workspace, window, cx);
1138 }
1139 })
1140 }),
1141 ))
1142 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1143 .child(
1144 button_container
1145 .child(
1146 Button::new(
1147 ("always_allow", tool_call_id.0),
1148 format!("Always Allow {root_command}"),
1149 )
1150 .icon(IconName::CheckDouble)
1151 .icon_position(IconPosition::Start)
1152 .icon_size(IconSize::XSmall)
1153 .icon_color(Color::Success)
1154 .label_size(LabelSize::Small)
1155 .on_click(cx.listener({
1156 let id = tool_call_id;
1157 move |this, _, _, cx| {
1158 this.authorize_tool_call(
1159 id,
1160 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1161 cx,
1162 );
1163 }
1164 })),
1165 )
1166 .child(
1167 Button::new(("allow", tool_call_id.0), "Allow")
1168 .icon(IconName::Check)
1169 .icon_position(IconPosition::Start)
1170 .icon_size(IconSize::XSmall)
1171 .icon_color(Color::Success)
1172 .label_size(LabelSize::Small)
1173 .on_click(cx.listener({
1174 let id = tool_call_id;
1175 move |this, _, _, cx| {
1176 this.authorize_tool_call(
1177 id,
1178 acp::ToolCallConfirmationOutcome::Allow,
1179 cx,
1180 );
1181 }
1182 })),
1183 )
1184 .child(
1185 Button::new(("reject", tool_call_id.0), "Reject")
1186 .icon(IconName::X)
1187 .icon_position(IconPosition::Start)
1188 .icon_size(IconSize::XSmall)
1189 .icon_color(Color::Error)
1190 .label_size(LabelSize::Small)
1191 .on_click(cx.listener({
1192 let id = tool_call_id;
1193 move |this, _, _, cx| {
1194 this.authorize_tool_call(
1195 id,
1196 acp::ToolCallConfirmationOutcome::Reject,
1197 cx,
1198 );
1199 }
1200 })),
1201 ),
1202 )
1203 .into_any(),
1204 ToolCallConfirmation::Mcp {
1205 server_name,
1206 tool_name: _,
1207 tool_display_name,
1208 description,
1209 } => confirmation_container
1210 .child(
1211 v_flex()
1212 .px_2()
1213 .pb_1p5()
1214 .child(format!("{server_name} - {tool_display_name}"))
1215 .children(description.clone().map(|description| {
1216 self.render_markdown(
1217 description,
1218 default_markdown_style(false, window, cx),
1219 )
1220 })),
1221 )
1222 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1223 .child(
1224 button_container
1225 .child(
1226 Button::new(
1227 ("always_allow_server", tool_call_id.0),
1228 format!("Always Allow {server_name}"),
1229 )
1230 .icon(IconName::CheckDouble)
1231 .icon_position(IconPosition::Start)
1232 .icon_size(IconSize::XSmall)
1233 .icon_color(Color::Success)
1234 .label_size(LabelSize::Small)
1235 .on_click(cx.listener({
1236 let id = tool_call_id;
1237 move |this, _, _, cx| {
1238 this.authorize_tool_call(
1239 id,
1240 acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
1241 cx,
1242 );
1243 }
1244 })),
1245 )
1246 .child(
1247 Button::new(
1248 ("always_allow_tool", tool_call_id.0),
1249 format!("Always Allow {tool_display_name}"),
1250 )
1251 .icon(IconName::CheckDouble)
1252 .icon_position(IconPosition::Start)
1253 .icon_size(IconSize::XSmall)
1254 .icon_color(Color::Success)
1255 .label_size(LabelSize::Small)
1256 .on_click(cx.listener({
1257 let id = tool_call_id;
1258 move |this, _, _, cx| {
1259 this.authorize_tool_call(
1260 id,
1261 acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
1262 cx,
1263 );
1264 }
1265 })),
1266 )
1267 .child(
1268 Button::new(("allow", tool_call_id.0), "Allow")
1269 .icon(IconName::Check)
1270 .icon_position(IconPosition::Start)
1271 .icon_size(IconSize::XSmall)
1272 .icon_color(Color::Success)
1273 .label_size(LabelSize::Small)
1274 .on_click(cx.listener({
1275 let id = tool_call_id;
1276 move |this, _, _, cx| {
1277 this.authorize_tool_call(
1278 id,
1279 acp::ToolCallConfirmationOutcome::Allow,
1280 cx,
1281 );
1282 }
1283 })),
1284 )
1285 .child(
1286 Button::new(("reject", tool_call_id.0), "Reject")
1287 .icon(IconName::X)
1288 .icon_position(IconPosition::Start)
1289 .icon_size(IconSize::XSmall)
1290 .icon_color(Color::Error)
1291 .label_size(LabelSize::Small)
1292 .on_click(cx.listener({
1293 let id = tool_call_id;
1294 move |this, _, _, cx| {
1295 this.authorize_tool_call(
1296 id,
1297 acp::ToolCallConfirmationOutcome::Reject,
1298 cx,
1299 );
1300 }
1301 })),
1302 ),
1303 )
1304 .into_any(),
1305 ToolCallConfirmation::Fetch { description, urls } => confirmation_container
1306 .child(
1307 v_flex()
1308 .px_2()
1309 .pb_1p5()
1310 .gap_1()
1311 .children(urls.iter().map(|url| {
1312 h_flex().child(
1313 Button::new(url.clone(), url)
1314 .icon(IconName::ArrowUpRight)
1315 .icon_color(Color::Muted)
1316 .icon_size(IconSize::XSmall)
1317 .on_click({
1318 let url = url.clone();
1319 move |_, _, cx| cx.open_url(&url)
1320 }),
1321 )
1322 }))
1323 .children(description.clone().map(|description| {
1324 self.render_markdown(
1325 description,
1326 default_markdown_style(false, window, cx),
1327 )
1328 })),
1329 )
1330 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1331 .child(
1332 button_container
1333 .child(
1334 Button::new(("always_allow", tool_call_id.0), "Always Allow")
1335 .icon(IconName::CheckDouble)
1336 .icon_position(IconPosition::Start)
1337 .icon_size(IconSize::XSmall)
1338 .icon_color(Color::Success)
1339 .label_size(LabelSize::Small)
1340 .on_click(cx.listener({
1341 let id = tool_call_id;
1342 move |this, _, _, cx| {
1343 this.authorize_tool_call(
1344 id,
1345 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1346 cx,
1347 );
1348 }
1349 })),
1350 )
1351 .child(
1352 Button::new(("allow", tool_call_id.0), "Allow")
1353 .icon(IconName::Check)
1354 .icon_position(IconPosition::Start)
1355 .icon_size(IconSize::XSmall)
1356 .icon_color(Color::Success)
1357 .label_size(LabelSize::Small)
1358 .on_click(cx.listener({
1359 let id = tool_call_id;
1360 move |this, _, _, cx| {
1361 this.authorize_tool_call(
1362 id,
1363 acp::ToolCallConfirmationOutcome::Allow,
1364 cx,
1365 );
1366 }
1367 })),
1368 )
1369 .child(
1370 Button::new(("reject", tool_call_id.0), "Reject")
1371 .icon(IconName::X)
1372 .icon_position(IconPosition::Start)
1373 .icon_size(IconSize::XSmall)
1374 .icon_color(Color::Error)
1375 .label_size(LabelSize::Small)
1376 .on_click(cx.listener({
1377 let id = tool_call_id;
1378 move |this, _, _, cx| {
1379 this.authorize_tool_call(
1380 id,
1381 acp::ToolCallConfirmationOutcome::Reject,
1382 cx,
1383 );
1384 }
1385 })),
1386 ),
1387 )
1388 .into_any(),
1389 ToolCallConfirmation::Other { description } => confirmation_container
1390 .child(v_flex().px_2().pb_1p5().child(self.render_markdown(
1391 description.clone(),
1392 default_markdown_style(false, window, cx),
1393 )))
1394 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1395 .child(
1396 button_container
1397 .child(
1398 Button::new(("always_allow", tool_call_id.0), "Always Allow")
1399 .icon(IconName::CheckDouble)
1400 .icon_position(IconPosition::Start)
1401 .icon_size(IconSize::XSmall)
1402 .icon_color(Color::Success)
1403 .label_size(LabelSize::Small)
1404 .on_click(cx.listener({
1405 let id = tool_call_id;
1406 move |this, _, _, cx| {
1407 this.authorize_tool_call(
1408 id,
1409 acp::ToolCallConfirmationOutcome::AlwaysAllow,
1410 cx,
1411 );
1412 }
1413 })),
1414 )
1415 .child(
1416 Button::new(("allow", tool_call_id.0), "Allow")
1417 .icon(IconName::Check)
1418 .icon_position(IconPosition::Start)
1419 .icon_size(IconSize::XSmall)
1420 .icon_color(Color::Success)
1421 .label_size(LabelSize::Small)
1422 .on_click(cx.listener({
1423 let id = tool_call_id;
1424 move |this, _, _, cx| {
1425 this.authorize_tool_call(
1426 id,
1427 acp::ToolCallConfirmationOutcome::Allow,
1428 cx,
1429 );
1430 }
1431 })),
1432 )
1433 .child(
1434 Button::new(("reject", tool_call_id.0), "Reject")
1435 .icon(IconName::X)
1436 .icon_position(IconPosition::Start)
1437 .icon_size(IconSize::XSmall)
1438 .icon_color(Color::Error)
1439 .label_size(LabelSize::Small)
1440 .on_click(cx.listener({
1441 let id = tool_call_id;
1442 move |this, _, _, cx| {
1443 this.authorize_tool_call(
1444 id,
1445 acp::ToolCallConfirmationOutcome::Reject,
1446 cx,
1447 );
1448 }
1449 })),
1450 ),
1451 )
1452 .into_any(),
1453 }
1454 }
1455
1456 fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
1457 v_flex()
1458 .h_full()
1459 .child(
1460 if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) {
1461 editor.clone().into_any_element()
1462 } else {
1463 Empty.into_any()
1464 },
1465 )
1466 .into_any()
1467 }
1468
1469 fn render_gemini_logo(&self) -> AnyElement {
1470 Icon::new(IconName::AiGemini)
1471 .color(Color::Muted)
1472 .size(IconSize::XLarge)
1473 .into_any_element()
1474 }
1475
1476 fn render_error_gemini_logo(&self) -> AnyElement {
1477 let logo = Icon::new(IconName::AiGemini)
1478 .color(Color::Muted)
1479 .size(IconSize::XLarge)
1480 .into_any_element();
1481
1482 h_flex()
1483 .relative()
1484 .justify_center()
1485 .child(div().opacity(0.3).child(logo))
1486 .child(
1487 h_flex().absolute().right_1().bottom_0().child(
1488 Icon::new(IconName::XCircle)
1489 .color(Color::Error)
1490 .size(IconSize::Small),
1491 ),
1492 )
1493 .into_any_element()
1494 }
1495
1496 fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement {
1497 v_flex()
1498 .size_full()
1499 .items_center()
1500 .justify_center()
1501 .child(
1502 if loading {
1503 h_flex()
1504 .justify_center()
1505 .child(self.render_gemini_logo())
1506 .with_animation(
1507 "pulsating_icon",
1508 Animation::new(Duration::from_secs(2))
1509 .repeat()
1510 .with_easing(pulsating_between(0.4, 1.0)),
1511 |icon, delta| icon.opacity(delta),
1512 ).into_any()
1513 } else {
1514 self.render_gemini_logo().into_any_element()
1515 }
1516 )
1517 .child(
1518 h_flex()
1519 .mt_4()
1520 .mb_1()
1521 .justify_center()
1522 .child(Headline::new(if loading {
1523 "Connecting to Gemini…"
1524 } else {
1525 "Welcome to Gemini"
1526 }).size(HeadlineSize::Medium)),
1527 )
1528 .child(
1529 div()
1530 .max_w_1_2()
1531 .text_sm()
1532 .text_center()
1533 .map(|this| if loading {
1534 this.invisible()
1535 } else {
1536 this.text_color(cx.theme().colors().text_muted)
1537 })
1538 .child("Ask questions, edit files, run commands.\nBe specific for the best results.")
1539 )
1540 .into_any()
1541 }
1542
1543 fn render_pending_auth_state(&self) -> AnyElement {
1544 v_flex()
1545 .items_center()
1546 .justify_center()
1547 .child(self.render_error_gemini_logo())
1548 .child(
1549 h_flex()
1550 .mt_4()
1551 .mb_1()
1552 .justify_center()
1553 .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
1554 )
1555 .into_any()
1556 }
1557
1558 fn render_error_state(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1559 let mut container = v_flex()
1560 .items_center()
1561 .justify_center()
1562 .child(self.render_error_gemini_logo())
1563 .child(
1564 v_flex()
1565 .mt_4()
1566 .mb_2()
1567 .gap_0p5()
1568 .text_center()
1569 .items_center()
1570 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1571 .child(
1572 Label::new(e.to_string())
1573 .size(LabelSize::Small)
1574 .color(Color::Muted),
1575 ),
1576 );
1577
1578 if matches!(e, LoadError::Unsupported { .. }) {
1579 container =
1580 container.child(Button::new("upgrade", "Upgrade Gemini to Latest").on_click(
1581 cx.listener(|this, _, window, cx| {
1582 this.workspace
1583 .update(cx, |workspace, cx| {
1584 let project = workspace.project().read(cx);
1585 let cwd = project.first_project_directory(cx);
1586 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1587 let command =
1588 "npm install -g @google/gemini-cli@latest".to_string();
1589 let spawn_in_terminal = task::SpawnInTerminal {
1590 id: task::TaskId("install".to_string()),
1591 full_label: command.clone(),
1592 label: command.clone(),
1593 command: Some(command.clone()),
1594 args: Vec::new(),
1595 command_label: command.clone(),
1596 cwd,
1597 env: Default::default(),
1598 use_new_terminal: true,
1599 allow_concurrent_runs: true,
1600 reveal: Default::default(),
1601 reveal_target: Default::default(),
1602 hide: Default::default(),
1603 shell,
1604 show_summary: true,
1605 show_command: true,
1606 show_rerun: false,
1607 };
1608 workspace
1609 .spawn_in_terminal(spawn_in_terminal, window, cx)
1610 .detach();
1611 })
1612 .ok();
1613 }),
1614 ));
1615 }
1616
1617 container.into_any()
1618 }
1619
1620 fn render_edits_bar(
1621 &self,
1622 thread_entity: &Entity<AcpThread>,
1623 window: &mut Window,
1624 cx: &Context<Self>,
1625 ) -> Option<AnyElement> {
1626 let thread = thread_entity.read(cx);
1627 let action_log = thread.action_log();
1628 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1629
1630 if changed_buffers.is_empty() {
1631 return None;
1632 }
1633
1634 let editor_bg_color = cx.theme().colors().editor_background;
1635 let active_color = cx.theme().colors().element_selected;
1636 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
1637
1638 let pending_edits = thread.has_pending_edit_tool_calls();
1639 let expanded = self.edits_expanded;
1640
1641 v_flex()
1642 .mt_1()
1643 .mx_2()
1644 .bg(bg_edit_files_disclosure)
1645 .border_1()
1646 .border_b_0()
1647 .border_color(cx.theme().colors().border)
1648 .rounded_t_md()
1649 .shadow(vec![gpui::BoxShadow {
1650 color: gpui::black().opacity(0.15),
1651 offset: point(px(1.), px(-1.)),
1652 blur_radius: px(3.),
1653 spread_radius: px(0.),
1654 }])
1655 .child(self.render_edits_bar_summary(
1656 action_log,
1657 &changed_buffers,
1658 expanded,
1659 pending_edits,
1660 window,
1661 cx,
1662 ))
1663 .when(expanded, |parent| {
1664 parent.child(self.render_edits_bar_files(
1665 action_log,
1666 &changed_buffers,
1667 pending_edits,
1668 cx,
1669 ))
1670 })
1671 .into_any()
1672 .into()
1673 }
1674
1675 fn render_edits_bar_summary(
1676 &self,
1677 action_log: &Entity<ActionLog>,
1678 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1679 expanded: bool,
1680 pending_edits: bool,
1681 window: &mut Window,
1682 cx: &Context<Self>,
1683 ) -> Div {
1684 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
1685
1686 let focus_handle = self.focus_handle(cx);
1687
1688 h_flex()
1689 .p_1()
1690 .justify_between()
1691 .when(expanded, |this| {
1692 this.border_b_1().border_color(cx.theme().colors().border)
1693 })
1694 .child(
1695 h_flex()
1696 .id("edits-container")
1697 .cursor_pointer()
1698 .w_full()
1699 .gap_1()
1700 .child(Disclosure::new("edits-disclosure", expanded))
1701 .map(|this| {
1702 if pending_edits {
1703 this.child(
1704 Label::new(format!(
1705 "Editing {} {}…",
1706 changed_buffers.len(),
1707 if changed_buffers.len() == 1 {
1708 "file"
1709 } else {
1710 "files"
1711 }
1712 ))
1713 .color(Color::Muted)
1714 .size(LabelSize::Small)
1715 .with_animation(
1716 "edit-label",
1717 Animation::new(Duration::from_secs(2))
1718 .repeat()
1719 .with_easing(pulsating_between(0.3, 0.7)),
1720 |label, delta| label.alpha(delta),
1721 ),
1722 )
1723 } else {
1724 this.child(
1725 Label::new("Edits")
1726 .size(LabelSize::Small)
1727 .color(Color::Muted),
1728 )
1729 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
1730 .child(
1731 Label::new(format!(
1732 "{} {}",
1733 changed_buffers.len(),
1734 if changed_buffers.len() == 1 {
1735 "file"
1736 } else {
1737 "files"
1738 }
1739 ))
1740 .size(LabelSize::Small)
1741 .color(Color::Muted),
1742 )
1743 }
1744 })
1745 .on_click(cx.listener(|this, _, _, cx| {
1746 this.edits_expanded = !this.edits_expanded;
1747 cx.notify();
1748 })),
1749 )
1750 .child(
1751 h_flex()
1752 .gap_1()
1753 .child(
1754 IconButton::new("review-changes", IconName::ListTodo)
1755 .icon_size(IconSize::Small)
1756 .tooltip({
1757 let focus_handle = focus_handle.clone();
1758 move |window, cx| {
1759 Tooltip::for_action_in(
1760 "Review Changes",
1761 &OpenAgentDiff,
1762 &focus_handle,
1763 window,
1764 cx,
1765 )
1766 }
1767 })
1768 .on_click(cx.listener(|_, _, window, cx| {
1769 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
1770 })),
1771 )
1772 .child(Divider::vertical().color(DividerColor::Border))
1773 .child(
1774 Button::new("reject-all-changes", "Reject All")
1775 .label_size(LabelSize::Small)
1776 .disabled(pending_edits)
1777 .when(pending_edits, |this| {
1778 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
1779 })
1780 .key_binding(
1781 KeyBinding::for_action_in(
1782 &RejectAll,
1783 &focus_handle.clone(),
1784 window,
1785 cx,
1786 )
1787 .map(|kb| kb.size(rems_from_px(10.))),
1788 )
1789 .on_click({
1790 let action_log = action_log.clone();
1791 cx.listener(move |_, _, _, cx| {
1792 action_log.update(cx, |action_log, cx| {
1793 action_log.reject_all_edits(cx).detach();
1794 })
1795 })
1796 }),
1797 )
1798 .child(
1799 Button::new("keep-all-changes", "Keep All")
1800 .label_size(LabelSize::Small)
1801 .disabled(pending_edits)
1802 .when(pending_edits, |this| {
1803 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
1804 })
1805 .key_binding(
1806 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
1807 .map(|kb| kb.size(rems_from_px(10.))),
1808 )
1809 .on_click({
1810 let action_log = action_log.clone();
1811 cx.listener(move |_, _, _, cx| {
1812 action_log.update(cx, |action_log, cx| {
1813 action_log.keep_all_edits(cx);
1814 })
1815 })
1816 }),
1817 ),
1818 )
1819 }
1820
1821 fn render_edits_bar_files(
1822 &self,
1823 action_log: &Entity<ActionLog>,
1824 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1825 pending_edits: bool,
1826 cx: &Context<Self>,
1827 ) -> Div {
1828 let editor_bg_color = cx.theme().colors().editor_background;
1829
1830 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
1831 |(index, (buffer, _diff))| {
1832 let file = buffer.read(cx).file()?;
1833 let path = file.path();
1834
1835 let file_path = path.parent().and_then(|parent| {
1836 let parent_str = parent.to_string_lossy();
1837
1838 if parent_str.is_empty() {
1839 None
1840 } else {
1841 Some(
1842 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
1843 .color(Color::Muted)
1844 .size(LabelSize::XSmall)
1845 .buffer_font(cx),
1846 )
1847 }
1848 });
1849
1850 let file_name = path.file_name().map(|name| {
1851 Label::new(name.to_string_lossy().to_string())
1852 .size(LabelSize::XSmall)
1853 .buffer_font(cx)
1854 });
1855
1856 let file_icon = FileIcons::get_icon(&path, cx)
1857 .map(Icon::from_path)
1858 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
1859 .unwrap_or_else(|| {
1860 Icon::new(IconName::File)
1861 .color(Color::Muted)
1862 .size(IconSize::Small)
1863 });
1864
1865 let overlay_gradient = linear_gradient(
1866 90.,
1867 linear_color_stop(editor_bg_color, 1.),
1868 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
1869 );
1870
1871 let element = h_flex()
1872 .group("edited-code")
1873 .id(("file-container", index))
1874 .relative()
1875 .py_1()
1876 .pl_2()
1877 .pr_1()
1878 .gap_2()
1879 .justify_between()
1880 .bg(editor_bg_color)
1881 .when(index < changed_buffers.len() - 1, |parent| {
1882 parent.border_color(cx.theme().colors().border).border_b_1()
1883 })
1884 .child(
1885 h_flex()
1886 .id(("file-name", index))
1887 .pr_8()
1888 .gap_1p5()
1889 .max_w_full()
1890 .overflow_x_scroll()
1891 .child(file_icon)
1892 .child(h_flex().gap_0p5().children(file_name).children(file_path))
1893 .on_click({
1894 let buffer = buffer.clone();
1895 cx.listener(move |this, _, window, cx| {
1896 this.open_edited_buffer(&buffer, window, cx);
1897 })
1898 }),
1899 )
1900 .child(
1901 h_flex()
1902 .gap_1()
1903 .visible_on_hover("edited-code")
1904 .child(
1905 Button::new("review", "Review")
1906 .label_size(LabelSize::Small)
1907 .on_click({
1908 let buffer = buffer.clone();
1909 cx.listener(move |this, _, window, cx| {
1910 this.open_edited_buffer(&buffer, window, cx);
1911 })
1912 }),
1913 )
1914 .child(Divider::vertical().color(DividerColor::BorderVariant))
1915 .child(
1916 Button::new("reject-file", "Reject")
1917 .label_size(LabelSize::Small)
1918 .disabled(pending_edits)
1919 .on_click({
1920 let buffer = buffer.clone();
1921 let action_log = action_log.clone();
1922 move |_, _, cx| {
1923 action_log.update(cx, |action_log, cx| {
1924 action_log
1925 .reject_edits_in_ranges(
1926 buffer.clone(),
1927 vec![Anchor::MIN..Anchor::MAX],
1928 cx,
1929 )
1930 .detach_and_log_err(cx);
1931 })
1932 }
1933 }),
1934 )
1935 .child(
1936 Button::new("keep-file", "Keep")
1937 .label_size(LabelSize::Small)
1938 .disabled(pending_edits)
1939 .on_click({
1940 let buffer = buffer.clone();
1941 let action_log = action_log.clone();
1942 move |_, _, cx| {
1943 action_log.update(cx, |action_log, cx| {
1944 action_log.keep_edits_in_range(
1945 buffer.clone(),
1946 Anchor::MIN..Anchor::MAX,
1947 cx,
1948 );
1949 })
1950 }
1951 }),
1952 ),
1953 )
1954 .child(
1955 div()
1956 .id("gradient-overlay")
1957 .absolute()
1958 .h_full()
1959 .w_12()
1960 .top_0()
1961 .bottom_0()
1962 .right(px(152.))
1963 .bg(overlay_gradient),
1964 );
1965
1966 Some(element)
1967 },
1968 ))
1969 }
1970
1971 fn render_message_editor(&mut self, cx: &mut Context<Self>) -> AnyElement {
1972 let settings = ThemeSettings::get_global(cx);
1973 let font_size = TextSize::Small
1974 .rems(cx)
1975 .to_pixels(settings.agent_font_size(cx));
1976 let line_height = settings.buffer_line_height.value() * font_size;
1977
1978 let text_style = TextStyle {
1979 color: cx.theme().colors().text,
1980 font_family: settings.buffer_font.family.clone(),
1981 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1982 font_features: settings.buffer_font.features.clone(),
1983 font_size: font_size.into(),
1984 line_height: line_height.into(),
1985 ..Default::default()
1986 };
1987
1988 EditorElement::new(
1989 &self.message_editor,
1990 EditorStyle {
1991 background: cx.theme().colors().editor_background,
1992 local_player: cx.theme().players().local(),
1993 text: text_style,
1994 syntax: cx.theme().syntax().clone(),
1995 ..Default::default()
1996 },
1997 )
1998 .into_any()
1999 }
2000
2001 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
2002 if self.thread().map_or(true, |thread| {
2003 thread.read(cx).status() == ThreadStatus::Idle
2004 }) {
2005 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
2006 IconButton::new("send-message", IconName::Send)
2007 .icon_color(Color::Accent)
2008 .style(ButtonStyle::Filled)
2009 .disabled(self.thread().is_none() || is_editor_empty)
2010 .on_click(cx.listener(|this, _, window, cx| {
2011 this.chat(&Chat, window, cx);
2012 }))
2013 .when(!is_editor_empty, |button| {
2014 button.tooltip(move |window, cx| Tooltip::for_action("Send", &Chat, window, cx))
2015 })
2016 .when(is_editor_empty, |button| {
2017 button.tooltip(Tooltip::text("Type a message to submit"))
2018 })
2019 .into_any_element()
2020 } else {
2021 IconButton::new("stop-generation", IconName::StopFilled)
2022 .icon_color(Color::Error)
2023 .style(ButtonStyle::Tinted(ui::TintColor::Error))
2024 .tooltip(move |window, cx| {
2025 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
2026 })
2027 .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
2028 .into_any_element()
2029 }
2030 }
2031
2032 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2033 let following = self
2034 .workspace
2035 .read_with(cx, |workspace, _| {
2036 workspace.is_being_followed(CollaboratorId::Agent)
2037 })
2038 .unwrap_or(false);
2039
2040 IconButton::new("follow-agent", IconName::Crosshair)
2041 .icon_size(IconSize::Small)
2042 .icon_color(Color::Muted)
2043 .toggle_state(following)
2044 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
2045 .tooltip(move |window, cx| {
2046 if following {
2047 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
2048 } else {
2049 Tooltip::with_meta(
2050 "Follow Agent",
2051 Some(&Follow),
2052 "Track the agent's location as it reads and edits files.",
2053 window,
2054 cx,
2055 )
2056 }
2057 })
2058 .on_click(cx.listener(move |this, _, window, cx| {
2059 this.workspace
2060 .update(cx, |workspace, cx| {
2061 if following {
2062 workspace.unfollow(CollaboratorId::Agent, window, cx);
2063 } else {
2064 workspace.follow(CollaboratorId::Agent, window, cx);
2065 }
2066 })
2067 .ok();
2068 }))
2069 }
2070
2071 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2072 let workspace = self.workspace.clone();
2073 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2074 Self::open_link(text, &workspace, window, cx);
2075 })
2076 }
2077
2078 fn open_link(
2079 url: SharedString,
2080 workspace: &WeakEntity<Workspace>,
2081 window: &mut Window,
2082 cx: &mut App,
2083 ) {
2084 let Some(workspace) = workspace.upgrade() else {
2085 cx.open_url(&url);
2086 return;
2087 };
2088
2089 if let Some(mention_path) = MentionPath::try_parse(&url) {
2090 workspace.update(cx, |workspace, cx| {
2091 let project = workspace.project();
2092 let Some((path, entry)) = project.update(cx, |project, cx| {
2093 let path = project.find_project_path(mention_path.path(), cx)?;
2094 let entry = project.entry_for_path(&path, cx)?;
2095 Some((path, entry))
2096 }) else {
2097 return;
2098 };
2099
2100 if entry.is_dir() {
2101 project.update(cx, |_, cx| {
2102 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2103 });
2104 } else {
2105 workspace
2106 .open_path(path, None, true, window, cx)
2107 .detach_and_log_err(cx);
2108 }
2109 })
2110 } else {
2111 cx.open_url(&url);
2112 }
2113 }
2114
2115 fn open_tool_call_location(
2116 &self,
2117 entry_ix: usize,
2118 location_ix: usize,
2119 window: &mut Window,
2120 cx: &mut Context<Self>,
2121 ) -> Option<()> {
2122 let location = self
2123 .thread()?
2124 .read(cx)
2125 .entries()
2126 .get(entry_ix)?
2127 .locations()?
2128 .get(location_ix)?;
2129
2130 let project_path = self
2131 .project
2132 .read(cx)
2133 .find_project_path(&location.path, cx)?;
2134
2135 let open_task = self
2136 .workspace
2137 .update(cx, |worskpace, cx| {
2138 worskpace.open_path(project_path, None, true, window, cx)
2139 })
2140 .log_err()?;
2141
2142 window
2143 .spawn(cx, async move |cx| {
2144 let item = open_task.await?;
2145
2146 let Some(active_editor) = item.downcast::<Editor>() else {
2147 return anyhow::Ok(());
2148 };
2149
2150 active_editor.update_in(cx, |editor, window, cx| {
2151 let snapshot = editor.buffer().read(cx).snapshot(cx);
2152 let first_hunk = editor
2153 .diff_hunks_in_ranges(
2154 &[editor::Anchor::min()..editor::Anchor::max()],
2155 &snapshot,
2156 )
2157 .next();
2158 if let Some(first_hunk) = first_hunk {
2159 let first_hunk_start = first_hunk.multi_buffer_range().start;
2160 editor.change_selections(Default::default(), window, cx, |selections| {
2161 selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
2162 })
2163 }
2164 })?;
2165
2166 anyhow::Ok(())
2167 })
2168 .detach_and_log_err(cx);
2169
2170 None
2171 }
2172
2173 pub fn open_thread_as_markdown(
2174 &self,
2175 workspace: Entity<Workspace>,
2176 window: &mut Window,
2177 cx: &mut App,
2178 ) -> Task<anyhow::Result<()>> {
2179 let markdown_language_task = workspace
2180 .read(cx)
2181 .app_state()
2182 .languages
2183 .language_for_name("Markdown");
2184
2185 let (thread_summary, markdown) = match &self.thread_state {
2186 ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
2187 let thread = thread.read(cx);
2188 (thread.title().to_string(), thread.to_markdown(cx))
2189 }
2190 ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())),
2191 };
2192
2193 window.spawn(cx, async move |cx| {
2194 let markdown_language = markdown_language_task.await?;
2195
2196 workspace.update_in(cx, |workspace, window, cx| {
2197 let project = workspace.project().clone();
2198
2199 if !project.read(cx).is_local() {
2200 anyhow::bail!("failed to open active thread as markdown in remote project");
2201 }
2202
2203 let buffer = project.update(cx, |project, cx| {
2204 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2205 });
2206 let buffer = cx.new(|cx| {
2207 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2208 });
2209
2210 workspace.add_item_to_active_pane(
2211 Box::new(cx.new(|cx| {
2212 let mut editor =
2213 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2214 editor.set_breadcrumb_header(thread_summary);
2215 editor
2216 })),
2217 None,
2218 true,
2219 window,
2220 cx,
2221 );
2222
2223 anyhow::Ok(())
2224 })??;
2225 anyhow::Ok(())
2226 })
2227 }
2228
2229 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
2230 self.list_state.scroll_to(ListOffset::default());
2231 cx.notify();
2232 }
2233}
2234
2235impl Focusable for AcpThreadView {
2236 fn focus_handle(&self, cx: &App) -> FocusHandle {
2237 self.message_editor.focus_handle(cx)
2238 }
2239}
2240
2241impl Render for AcpThreadView {
2242 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2243 let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText)
2244 .icon_size(IconSize::XSmall)
2245 .icon_color(Color::Ignored)
2246 .tooltip(Tooltip::text("Open Thread as Markdown"))
2247 .on_click(cx.listener(move |this, _, window, cx| {
2248 if let Some(workspace) = this.workspace.upgrade() {
2249 this.open_thread_as_markdown(workspace, window, cx)
2250 .detach_and_log_err(cx);
2251 }
2252 }));
2253
2254 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt)
2255 .icon_size(IconSize::XSmall)
2256 .icon_color(Color::Ignored)
2257 .tooltip(Tooltip::text("Scroll To Top"))
2258 .on_click(cx.listener(move |this, _, _, cx| {
2259 this.scroll_to_top(cx);
2260 }));
2261
2262 v_flex()
2263 .size_full()
2264 .key_context("AcpThread")
2265 .on_action(cx.listener(Self::chat))
2266 .on_action(cx.listener(Self::previous_history_message))
2267 .on_action(cx.listener(Self::next_history_message))
2268 .on_action(cx.listener(Self::open_agent_diff))
2269 .child(match &self.thread_state {
2270 ThreadState::Unauthenticated { .. } => v_flex()
2271 .p_2()
2272 .flex_1()
2273 .items_center()
2274 .justify_center()
2275 .child(self.render_pending_auth_state())
2276 .child(h_flex().mt_1p5().justify_center().child(
2277 Button::new("sign-in", "Sign in to Gemini").on_click(
2278 cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
2279 ),
2280 )),
2281 ThreadState::Loading { .. } => {
2282 v_flex().flex_1().child(self.render_empty_state(true, cx))
2283 }
2284 ThreadState::LoadError(e) => v_flex()
2285 .p_2()
2286 .flex_1()
2287 .items_center()
2288 .justify_center()
2289 .child(self.render_error_state(e, cx)),
2290 ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| {
2291 if self.list_state.item_count() > 0 {
2292 this.child(
2293 list(self.list_state.clone())
2294 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
2295 .flex_grow()
2296 .into_any(),
2297 )
2298 .child(
2299 h_flex()
2300 .group("controls")
2301 .mt_1()
2302 .mr_1()
2303 .py_2()
2304 .px(RESPONSE_PADDING_X)
2305 .opacity(0.4)
2306 .hover(|style| style.opacity(1.))
2307 .gap_1()
2308 .flex_wrap()
2309 .justify_end()
2310 .child(open_as_markdown)
2311 .child(scroll_to_top)
2312 .into_any_element(),
2313 )
2314 .children(match thread.read(cx).status() {
2315 ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None,
2316 ThreadStatus::Generating => div()
2317 .px_5()
2318 .py_2()
2319 .child(LoadingLabel::new("").size(LabelSize::Small))
2320 .into(),
2321 })
2322 .children(self.render_edits_bar(&thread, window, cx))
2323 } else {
2324 this.child(self.render_empty_state(false, cx))
2325 }
2326 }),
2327 })
2328 .when_some(self.last_error.clone(), |el, error| {
2329 el.child(
2330 div()
2331 .p_2()
2332 .text_xs()
2333 .border_t_1()
2334 .border_color(cx.theme().colors().border)
2335 .bg(cx.theme().status().error_background)
2336 .child(
2337 self.render_markdown(error, default_markdown_style(false, window, cx)),
2338 ),
2339 )
2340 })
2341 .child(
2342 v_flex()
2343 .p_2()
2344 .pt_3()
2345 .gap_1()
2346 .bg(cx.theme().colors().editor_background)
2347 .border_t_1()
2348 .border_color(cx.theme().colors().border)
2349 .child(self.render_message_editor(cx))
2350 .child(
2351 h_flex()
2352 .justify_between()
2353 .child(self.render_follow_toggle(cx))
2354 .child(self.render_send_button(cx)),
2355 ),
2356 )
2357 }
2358}
2359
2360fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
2361 let mut style = default_markdown_style(false, window, cx);
2362 let mut text_style = window.text_style();
2363 let theme_settings = ThemeSettings::get_global(cx);
2364
2365 let buffer_font = theme_settings.buffer_font.family.clone();
2366 let buffer_font_size = TextSize::Small.rems(cx);
2367
2368 text_style.refine(&TextStyleRefinement {
2369 font_family: Some(buffer_font),
2370 font_size: Some(buffer_font_size.into()),
2371 ..Default::default()
2372 });
2373
2374 style.base_text_style = text_style;
2375 style.link_callback = Some(Rc::new(move |url, cx| {
2376 if MentionPath::try_parse(url).is_some() {
2377 let colors = cx.theme().colors();
2378 Some(TextStyleRefinement {
2379 background_color: Some(colors.element_background),
2380 ..Default::default()
2381 })
2382 } else {
2383 None
2384 }
2385 }));
2386 style
2387}
2388
2389fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
2390 let theme_settings = ThemeSettings::get_global(cx);
2391 let colors = cx.theme().colors();
2392
2393 let buffer_font_size = TextSize::Small.rems(cx);
2394
2395 let mut text_style = window.text_style();
2396 let line_height = buffer_font_size * 1.75;
2397
2398 let font_family = if buffer_font {
2399 theme_settings.buffer_font.family.clone()
2400 } else {
2401 theme_settings.ui_font.family.clone()
2402 };
2403
2404 let font_size = if buffer_font {
2405 TextSize::Small.rems(cx)
2406 } else {
2407 TextSize::Default.rems(cx)
2408 };
2409
2410 text_style.refine(&TextStyleRefinement {
2411 font_family: Some(font_family),
2412 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
2413 font_features: Some(theme_settings.ui_font.features.clone()),
2414 font_size: Some(font_size.into()),
2415 line_height: Some(line_height.into()),
2416 color: Some(cx.theme().colors().text),
2417 ..Default::default()
2418 });
2419
2420 MarkdownStyle {
2421 base_text_style: text_style.clone(),
2422 syntax: cx.theme().syntax().clone(),
2423 selection_background_color: cx.theme().colors().element_selection_background,
2424 code_block_overflow_x_scroll: true,
2425 table_overflow_x_scroll: true,
2426 heading_level_styles: Some(HeadingLevelStyles {
2427 h1: Some(TextStyleRefinement {
2428 font_size: Some(rems(1.15).into()),
2429 ..Default::default()
2430 }),
2431 h2: Some(TextStyleRefinement {
2432 font_size: Some(rems(1.1).into()),
2433 ..Default::default()
2434 }),
2435 h3: Some(TextStyleRefinement {
2436 font_size: Some(rems(1.05).into()),
2437 ..Default::default()
2438 }),
2439 h4: Some(TextStyleRefinement {
2440 font_size: Some(rems(1.).into()),
2441 ..Default::default()
2442 }),
2443 h5: Some(TextStyleRefinement {
2444 font_size: Some(rems(0.95).into()),
2445 ..Default::default()
2446 }),
2447 h6: Some(TextStyleRefinement {
2448 font_size: Some(rems(0.875).into()),
2449 ..Default::default()
2450 }),
2451 }),
2452 code_block: StyleRefinement {
2453 padding: EdgesRefinement {
2454 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2455 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2456 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2457 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2458 },
2459 margin: EdgesRefinement {
2460 top: Some(Length::Definite(Pixels(8.).into())),
2461 left: Some(Length::Definite(Pixels(0.).into())),
2462 right: Some(Length::Definite(Pixels(0.).into())),
2463 bottom: Some(Length::Definite(Pixels(12.).into())),
2464 },
2465 border_style: Some(BorderStyle::Solid),
2466 border_widths: EdgesRefinement {
2467 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
2468 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
2469 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
2470 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
2471 },
2472 border_color: Some(colors.border_variant),
2473 background: Some(colors.editor_background.into()),
2474 text: Some(TextStyleRefinement {
2475 font_family: Some(theme_settings.buffer_font.family.clone()),
2476 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
2477 font_features: Some(theme_settings.buffer_font.features.clone()),
2478 font_size: Some(buffer_font_size.into()),
2479 ..Default::default()
2480 }),
2481 ..Default::default()
2482 },
2483 inline_code: TextStyleRefinement {
2484 font_family: Some(theme_settings.buffer_font.family.clone()),
2485 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
2486 font_features: Some(theme_settings.buffer_font.features.clone()),
2487 font_size: Some(buffer_font_size.into()),
2488 background_color: Some(colors.editor_foreground.opacity(0.08)),
2489 ..Default::default()
2490 },
2491 link: TextStyleRefinement {
2492 background_color: Some(colors.editor_foreground.opacity(0.025)),
2493 underline: Some(UnderlineStyle {
2494 color: Some(colors.text_accent.opacity(0.5)),
2495 thickness: px(1.),
2496 ..Default::default()
2497 }),
2498 ..Default::default()
2499 },
2500 ..Default::default()
2501 }
2502}