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