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(
1134 v_flex()
1135 .px_2()
1136 .pb_1p5()
1137 .children(description.clone().map(|description| {
1138 self.render_markdown(
1139 description,
1140 default_markdown_style(false, window, cx),
1141 )
1142 .on_url_click({
1143 let workspace = self.workspace.clone();
1144 move |text, window, cx| {
1145 Self::open_link(text, &workspace, window, cx);
1146 }
1147 })
1148 })),
1149 )
1150 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1151 .child(self.render_confirmation_buttons(
1152 &[AlwaysAllowOption {
1153 id: "always_allow",
1154 label: format!("Always Allow {root_command}").into(),
1155 outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
1156 }],
1157 tool_call_id,
1158 cx,
1159 ))
1160 .into_any(),
1161 ToolCallConfirmation::Mcp {
1162 server_name,
1163 tool_name: _,
1164 tool_display_name,
1165 description,
1166 } => confirmation_container
1167 .child(
1168 v_flex()
1169 .px_2()
1170 .pb_1p5()
1171 .child(format!("{server_name} - {tool_display_name}"))
1172 .children(description.clone().map(|description| {
1173 self.render_markdown(
1174 description,
1175 default_markdown_style(false, window, cx),
1176 )
1177 })),
1178 )
1179 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1180 .child(self.render_confirmation_buttons(
1181 &[
1182 AlwaysAllowOption {
1183 id: "always_allow_server",
1184 label: format!("Always Allow {server_name}").into(),
1185 outcome: acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
1186 },
1187 AlwaysAllowOption {
1188 id: "always_allow_tool",
1189 label: format!("Always Allow {tool_display_name}").into(),
1190 outcome: acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
1191 },
1192 ],
1193 tool_call_id,
1194 cx,
1195 ))
1196 .into_any(),
1197 ToolCallConfirmation::Fetch { description, urls } => confirmation_container
1198 .child(
1199 v_flex()
1200 .px_2()
1201 .pb_1p5()
1202 .gap_1()
1203 .children(urls.iter().map(|url| {
1204 h_flex().child(
1205 Button::new(url.clone(), url)
1206 .icon(IconName::ArrowUpRight)
1207 .icon_color(Color::Muted)
1208 .icon_size(IconSize::XSmall)
1209 .on_click({
1210 let url = url.clone();
1211 move |_, _, cx| cx.open_url(&url)
1212 }),
1213 )
1214 }))
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(self.render_confirmation_buttons(
1224 &[AlwaysAllowOption {
1225 id: "always_allow",
1226 label: "Always Allow".into(),
1227 outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
1228 }],
1229 tool_call_id,
1230 cx,
1231 ))
1232 .into_any(),
1233 ToolCallConfirmation::Other { description } => confirmation_container
1234 .child(v_flex().px_2().pb_1p5().child(self.render_markdown(
1235 description.clone(),
1236 default_markdown_style(false, window, cx),
1237 )))
1238 .children(content.map(|content| self.render_tool_call_content(content, window, cx)))
1239 .child(self.render_confirmation_buttons(
1240 &[AlwaysAllowOption {
1241 id: "always_allow",
1242 label: "Always Allow".into(),
1243 outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
1244 }],
1245 tool_call_id,
1246 cx,
1247 ))
1248 .into_any(),
1249 }
1250 }
1251
1252 fn render_confirmation_buttons(
1253 &self,
1254 always_allow_options: &[AlwaysAllowOption],
1255 tool_call_id: ToolCallId,
1256 cx: &Context<Self>,
1257 ) -> Div {
1258 h_flex()
1259 .pt_1p5()
1260 .px_1p5()
1261 .gap_1()
1262 .justify_end()
1263 .border_t_1()
1264 .border_color(self.tool_card_border_color(cx))
1265 .when(self.agent.supports_always_allow(), |this| {
1266 this.children(always_allow_options.into_iter().map(|always_allow_option| {
1267 let outcome = always_allow_option.outcome;
1268 Button::new(
1269 (always_allow_option.id, tool_call_id.0),
1270 always_allow_option.label.clone(),
1271 )
1272 .icon(IconName::CheckDouble)
1273 .icon_position(IconPosition::Start)
1274 .icon_size(IconSize::XSmall)
1275 .icon_color(Color::Success)
1276 .on_click(cx.listener({
1277 let id = tool_call_id;
1278 move |this, _, _, cx| {
1279 this.authorize_tool_call(id, outcome, cx);
1280 }
1281 }))
1282 }))
1283 })
1284 .child(
1285 Button::new(("allow", tool_call_id.0), "Allow")
1286 .icon(IconName::Check)
1287 .icon_position(IconPosition::Start)
1288 .icon_size(IconSize::XSmall)
1289 .icon_color(Color::Success)
1290 .on_click(cx.listener({
1291 let id = tool_call_id;
1292 move |this, _, _, cx| {
1293 this.authorize_tool_call(
1294 id,
1295 acp::ToolCallConfirmationOutcome::Allow,
1296 cx,
1297 );
1298 }
1299 })),
1300 )
1301 .child(
1302 Button::new(("reject", tool_call_id.0), "Reject")
1303 .icon(IconName::X)
1304 .icon_position(IconPosition::Start)
1305 .icon_size(IconSize::XSmall)
1306 .icon_color(Color::Error)
1307 .on_click(cx.listener({
1308 let id = tool_call_id;
1309 move |this, _, _, cx| {
1310 this.authorize_tool_call(
1311 id,
1312 acp::ToolCallConfirmationOutcome::Reject,
1313 cx,
1314 );
1315 }
1316 })),
1317 )
1318 }
1319
1320 fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
1321 v_flex()
1322 .h_full()
1323 .child(
1324 if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) {
1325 editor.clone().into_any_element()
1326 } else {
1327 Empty.into_any()
1328 },
1329 )
1330 .into_any()
1331 }
1332
1333 fn render_agent_logo(&self) -> AnyElement {
1334 Icon::new(self.agent.logo())
1335 .color(Color::Muted)
1336 .size(IconSize::XLarge)
1337 .into_any_element()
1338 }
1339
1340 fn render_error_agent_logo(&self) -> AnyElement {
1341 let logo = Icon::new(self.agent.logo())
1342 .color(Color::Muted)
1343 .size(IconSize::XLarge)
1344 .into_any_element();
1345
1346 h_flex()
1347 .relative()
1348 .justify_center()
1349 .child(div().opacity(0.3).child(logo))
1350 .child(
1351 h_flex().absolute().right_1().bottom_0().child(
1352 Icon::new(IconName::XCircle)
1353 .color(Color::Error)
1354 .size(IconSize::Small),
1355 ),
1356 )
1357 .into_any_element()
1358 }
1359
1360 fn render_empty_state(&self, cx: &App) -> AnyElement {
1361 let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
1362
1363 v_flex()
1364 .size_full()
1365 .items_center()
1366 .justify_center()
1367 .child(if loading {
1368 h_flex()
1369 .justify_center()
1370 .child(self.render_agent_logo())
1371 .with_animation(
1372 "pulsating_icon",
1373 Animation::new(Duration::from_secs(2))
1374 .repeat()
1375 .with_easing(pulsating_between(0.4, 1.0)),
1376 |icon, delta| icon.opacity(delta),
1377 )
1378 .into_any()
1379 } else {
1380 self.render_agent_logo().into_any_element()
1381 })
1382 .child(h_flex().mt_4().mb_1().justify_center().child(if loading {
1383 div()
1384 .child(LoadingLabel::new("").size(LabelSize::Large))
1385 .into_any_element()
1386 } else {
1387 Headline::new(self.agent.empty_state_headline())
1388 .size(HeadlineSize::Medium)
1389 .into_any_element()
1390 }))
1391 .child(
1392 div()
1393 .max_w_1_2()
1394 .text_sm()
1395 .text_center()
1396 .map(|this| {
1397 if loading {
1398 this.invisible()
1399 } else {
1400 this.text_color(cx.theme().colors().text_muted)
1401 }
1402 })
1403 .child(self.agent.empty_state_message()),
1404 )
1405 .into_any()
1406 }
1407
1408 fn render_pending_auth_state(&self) -> AnyElement {
1409 v_flex()
1410 .items_center()
1411 .justify_center()
1412 .child(self.render_error_agent_logo())
1413 .child(
1414 h_flex()
1415 .mt_4()
1416 .mb_1()
1417 .justify_center()
1418 .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
1419 )
1420 .into_any()
1421 }
1422
1423 fn render_error_state(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1424 let mut container = v_flex()
1425 .items_center()
1426 .justify_center()
1427 .child(self.render_error_agent_logo())
1428 .child(
1429 v_flex()
1430 .mt_4()
1431 .mb_2()
1432 .gap_0p5()
1433 .text_center()
1434 .items_center()
1435 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1436 .child(
1437 Label::new(e.to_string())
1438 .size(LabelSize::Small)
1439 .color(Color::Muted),
1440 ),
1441 );
1442
1443 if let LoadError::Unsupported {
1444 upgrade_message,
1445 upgrade_command,
1446 ..
1447 } = &e
1448 {
1449 let upgrade_message = upgrade_message.clone();
1450 let upgrade_command = upgrade_command.clone();
1451 container = container.child(Button::new("upgrade", upgrade_message).on_click(
1452 cx.listener(move |this, _, window, cx| {
1453 this.workspace
1454 .update(cx, |workspace, cx| {
1455 let project = workspace.project().read(cx);
1456 let cwd = project.first_project_directory(cx);
1457 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1458 let spawn_in_terminal = task::SpawnInTerminal {
1459 id: task::TaskId("install".to_string()),
1460 full_label: upgrade_command.clone(),
1461 label: upgrade_command.clone(),
1462 command: Some(upgrade_command.clone()),
1463 args: Vec::new(),
1464 command_label: upgrade_command.clone(),
1465 cwd,
1466 env: Default::default(),
1467 use_new_terminal: true,
1468 allow_concurrent_runs: true,
1469 reveal: Default::default(),
1470 reveal_target: Default::default(),
1471 hide: Default::default(),
1472 shell,
1473 show_summary: true,
1474 show_command: true,
1475 show_rerun: false,
1476 };
1477 workspace
1478 .spawn_in_terminal(spawn_in_terminal, window, cx)
1479 .detach();
1480 })
1481 .ok();
1482 }),
1483 ));
1484 }
1485
1486 container.into_any()
1487 }
1488
1489 fn render_activity_bar(
1490 &self,
1491 thread_entity: &Entity<AcpThread>,
1492 window: &mut Window,
1493 cx: &Context<Self>,
1494 ) -> Option<AnyElement> {
1495 let thread = thread_entity.read(cx);
1496 let action_log = thread.action_log();
1497 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1498 let plan = thread.plan();
1499
1500 if changed_buffers.is_empty() && plan.is_empty() {
1501 return None;
1502 }
1503
1504 let editor_bg_color = cx.theme().colors().editor_background;
1505 let active_color = cx.theme().colors().element_selected;
1506 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
1507
1508 let pending_edits = thread.has_pending_edit_tool_calls();
1509
1510 v_flex()
1511 .mt_1()
1512 .mx_2()
1513 .bg(bg_edit_files_disclosure)
1514 .border_1()
1515 .border_b_0()
1516 .border_color(cx.theme().colors().border)
1517 .rounded_t_md()
1518 .shadow(vec![gpui::BoxShadow {
1519 color: gpui::black().opacity(0.15),
1520 offset: point(px(1.), px(-1.)),
1521 blur_radius: px(3.),
1522 spread_radius: px(0.),
1523 }])
1524 .when(!plan.is_empty(), |this| {
1525 this.child(self.render_plan_summary(plan, window, cx))
1526 .when(self.plan_expanded, |parent| {
1527 parent.child(self.render_plan_entries(plan, window, cx))
1528 })
1529 })
1530 .when(!changed_buffers.is_empty(), |this| {
1531 this.child(Divider::horizontal())
1532 .child(self.render_edits_summary(
1533 action_log,
1534 &changed_buffers,
1535 self.edits_expanded,
1536 pending_edits,
1537 window,
1538 cx,
1539 ))
1540 .when(self.edits_expanded, |parent| {
1541 parent.child(self.render_edited_files(
1542 action_log,
1543 &changed_buffers,
1544 pending_edits,
1545 cx,
1546 ))
1547 })
1548 })
1549 .into_any()
1550 .into()
1551 }
1552
1553 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
1554 let stats = plan.stats();
1555
1556 let title = if let Some(entry) = stats.in_progress_entry
1557 && !self.plan_expanded
1558 {
1559 h_flex()
1560 .w_full()
1561 .gap_1()
1562 .text_xs()
1563 .text_color(cx.theme().colors().text_muted)
1564 .justify_between()
1565 .child(
1566 h_flex()
1567 .gap_1()
1568 .child(
1569 Label::new("Current:")
1570 .size(LabelSize::Small)
1571 .color(Color::Muted),
1572 )
1573 .child(MarkdownElement::new(
1574 entry.content.clone(),
1575 plan_label_markdown_style(&entry.status, window, cx),
1576 )),
1577 )
1578 .when(stats.pending > 0, |this| {
1579 this.child(
1580 Label::new(format!("{} left", stats.pending))
1581 .size(LabelSize::Small)
1582 .color(Color::Muted)
1583 .mr_1(),
1584 )
1585 })
1586 } else {
1587 let status_label = if stats.pending == 0 {
1588 "All Done".to_string()
1589 } else if stats.completed == 0 {
1590 format!("{}", plan.entries.len())
1591 } else {
1592 format!("{}/{}", stats.completed, plan.entries.len())
1593 };
1594
1595 h_flex()
1596 .w_full()
1597 .gap_1()
1598 .justify_between()
1599 .child(
1600 Label::new("Plan")
1601 .size(LabelSize::Small)
1602 .color(Color::Muted),
1603 )
1604 .child(
1605 Label::new(status_label)
1606 .size(LabelSize::Small)
1607 .color(Color::Muted)
1608 .mr_1(),
1609 )
1610 };
1611
1612 h_flex()
1613 .p_1()
1614 .justify_between()
1615 .when(self.plan_expanded, |this| {
1616 this.border_b_1().border_color(cx.theme().colors().border)
1617 })
1618 .child(
1619 h_flex()
1620 .id("plan_summary")
1621 .w_full()
1622 .gap_1()
1623 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
1624 .child(title)
1625 .on_click(cx.listener(|this, _, _, cx| {
1626 this.plan_expanded = !this.plan_expanded;
1627 cx.notify();
1628 })),
1629 )
1630 }
1631
1632 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
1633 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
1634 let element = h_flex()
1635 .py_1()
1636 .px_2()
1637 .gap_2()
1638 .justify_between()
1639 .bg(cx.theme().colors().editor_background)
1640 .when(index < plan.entries.len() - 1, |parent| {
1641 parent.border_color(cx.theme().colors().border).border_b_1()
1642 })
1643 .child(
1644 h_flex()
1645 .id(("plan_entry", index))
1646 .gap_1p5()
1647 .max_w_full()
1648 .overflow_x_scroll()
1649 .text_xs()
1650 .text_color(cx.theme().colors().text_muted)
1651 .child(match entry.status {
1652 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
1653 .size(IconSize::Small)
1654 .color(Color::Muted)
1655 .into_any_element(),
1656 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
1657 .size(IconSize::Small)
1658 .color(Color::Accent)
1659 .with_animation(
1660 "running",
1661 Animation::new(Duration::from_secs(2)).repeat(),
1662 |icon, delta| {
1663 icon.transform(Transformation::rotate(percentage(delta)))
1664 },
1665 )
1666 .into_any_element(),
1667 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
1668 .size(IconSize::Small)
1669 .color(Color::Success)
1670 .into_any_element(),
1671 })
1672 .child(MarkdownElement::new(
1673 entry.content.clone(),
1674 plan_label_markdown_style(&entry.status, window, cx),
1675 )),
1676 );
1677
1678 Some(element)
1679 }))
1680 }
1681
1682 fn render_edits_summary(
1683 &self,
1684 action_log: &Entity<ActionLog>,
1685 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1686 expanded: bool,
1687 pending_edits: bool,
1688 window: &mut Window,
1689 cx: &Context<Self>,
1690 ) -> Div {
1691 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
1692
1693 let focus_handle = self.focus_handle(cx);
1694
1695 h_flex()
1696 .p_1()
1697 .justify_between()
1698 .when(expanded, |this| {
1699 this.border_b_1().border_color(cx.theme().colors().border)
1700 })
1701 .child(
1702 h_flex()
1703 .id("edits-container")
1704 .cursor_pointer()
1705 .w_full()
1706 .gap_1()
1707 .child(Disclosure::new("edits-disclosure", expanded))
1708 .map(|this| {
1709 if pending_edits {
1710 this.child(
1711 Label::new(format!(
1712 "Editing {} {}…",
1713 changed_buffers.len(),
1714 if changed_buffers.len() == 1 {
1715 "file"
1716 } else {
1717 "files"
1718 }
1719 ))
1720 .color(Color::Muted)
1721 .size(LabelSize::Small)
1722 .with_animation(
1723 "edit-label",
1724 Animation::new(Duration::from_secs(2))
1725 .repeat()
1726 .with_easing(pulsating_between(0.3, 0.7)),
1727 |label, delta| label.alpha(delta),
1728 ),
1729 )
1730 } else {
1731 this.child(
1732 Label::new("Edits")
1733 .size(LabelSize::Small)
1734 .color(Color::Muted),
1735 )
1736 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
1737 .child(
1738 Label::new(format!(
1739 "{} {}",
1740 changed_buffers.len(),
1741 if changed_buffers.len() == 1 {
1742 "file"
1743 } else {
1744 "files"
1745 }
1746 ))
1747 .size(LabelSize::Small)
1748 .color(Color::Muted),
1749 )
1750 }
1751 })
1752 .on_click(cx.listener(|this, _, _, cx| {
1753 this.edits_expanded = !this.edits_expanded;
1754 cx.notify();
1755 })),
1756 )
1757 .child(
1758 h_flex()
1759 .gap_1()
1760 .child(
1761 IconButton::new("review-changes", IconName::ListTodo)
1762 .icon_size(IconSize::Small)
1763 .tooltip({
1764 let focus_handle = focus_handle.clone();
1765 move |window, cx| {
1766 Tooltip::for_action_in(
1767 "Review Changes",
1768 &OpenAgentDiff,
1769 &focus_handle,
1770 window,
1771 cx,
1772 )
1773 }
1774 })
1775 .on_click(cx.listener(|_, _, window, cx| {
1776 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
1777 })),
1778 )
1779 .child(Divider::vertical().color(DividerColor::Border))
1780 .child(
1781 Button::new("reject-all-changes", "Reject All")
1782 .label_size(LabelSize::Small)
1783 .disabled(pending_edits)
1784 .when(pending_edits, |this| {
1785 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
1786 })
1787 .key_binding(
1788 KeyBinding::for_action_in(
1789 &RejectAll,
1790 &focus_handle.clone(),
1791 window,
1792 cx,
1793 )
1794 .map(|kb| kb.size(rems_from_px(10.))),
1795 )
1796 .on_click({
1797 let action_log = action_log.clone();
1798 cx.listener(move |_, _, _, cx| {
1799 action_log.update(cx, |action_log, cx| {
1800 action_log.reject_all_edits(cx).detach();
1801 })
1802 })
1803 }),
1804 )
1805 .child(
1806 Button::new("keep-all-changes", "Keep All")
1807 .label_size(LabelSize::Small)
1808 .disabled(pending_edits)
1809 .when(pending_edits, |this| {
1810 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
1811 })
1812 .key_binding(
1813 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
1814 .map(|kb| kb.size(rems_from_px(10.))),
1815 )
1816 .on_click({
1817 let action_log = action_log.clone();
1818 cx.listener(move |_, _, _, cx| {
1819 action_log.update(cx, |action_log, cx| {
1820 action_log.keep_all_edits(cx);
1821 })
1822 })
1823 }),
1824 ),
1825 )
1826 }
1827
1828 fn render_edited_files(
1829 &self,
1830 action_log: &Entity<ActionLog>,
1831 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1832 pending_edits: bool,
1833 cx: &Context<Self>,
1834 ) -> Div {
1835 let editor_bg_color = cx.theme().colors().editor_background;
1836
1837 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
1838 |(index, (buffer, _diff))| {
1839 let file = buffer.read(cx).file()?;
1840 let path = file.path();
1841
1842 let file_path = path.parent().and_then(|parent| {
1843 let parent_str = parent.to_string_lossy();
1844
1845 if parent_str.is_empty() {
1846 None
1847 } else {
1848 Some(
1849 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
1850 .color(Color::Muted)
1851 .size(LabelSize::XSmall)
1852 .buffer_font(cx),
1853 )
1854 }
1855 });
1856
1857 let file_name = path.file_name().map(|name| {
1858 Label::new(name.to_string_lossy().to_string())
1859 .size(LabelSize::XSmall)
1860 .buffer_font(cx)
1861 });
1862
1863 let file_icon = FileIcons::get_icon(&path, cx)
1864 .map(Icon::from_path)
1865 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
1866 .unwrap_or_else(|| {
1867 Icon::new(IconName::File)
1868 .color(Color::Muted)
1869 .size(IconSize::Small)
1870 });
1871
1872 let overlay_gradient = linear_gradient(
1873 90.,
1874 linear_color_stop(editor_bg_color, 1.),
1875 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
1876 );
1877
1878 let element = h_flex()
1879 .group("edited-code")
1880 .id(("file-container", index))
1881 .relative()
1882 .py_1()
1883 .pl_2()
1884 .pr_1()
1885 .gap_2()
1886 .justify_between()
1887 .bg(editor_bg_color)
1888 .when(index < changed_buffers.len() - 1, |parent| {
1889 parent.border_color(cx.theme().colors().border).border_b_1()
1890 })
1891 .child(
1892 h_flex()
1893 .id(("file-name", index))
1894 .pr_8()
1895 .gap_1p5()
1896 .max_w_full()
1897 .overflow_x_scroll()
1898 .child(file_icon)
1899 .child(h_flex().gap_0p5().children(file_name).children(file_path))
1900 .on_click({
1901 let buffer = buffer.clone();
1902 cx.listener(move |this, _, window, cx| {
1903 this.open_edited_buffer(&buffer, window, cx);
1904 })
1905 }),
1906 )
1907 .child(
1908 h_flex()
1909 .gap_1()
1910 .visible_on_hover("edited-code")
1911 .child(
1912 Button::new("review", "Review")
1913 .label_size(LabelSize::Small)
1914 .on_click({
1915 let buffer = buffer.clone();
1916 cx.listener(move |this, _, window, cx| {
1917 this.open_edited_buffer(&buffer, window, cx);
1918 })
1919 }),
1920 )
1921 .child(Divider::vertical().color(DividerColor::BorderVariant))
1922 .child(
1923 Button::new("reject-file", "Reject")
1924 .label_size(LabelSize::Small)
1925 .disabled(pending_edits)
1926 .on_click({
1927 let buffer = buffer.clone();
1928 let action_log = action_log.clone();
1929 move |_, _, cx| {
1930 action_log.update(cx, |action_log, cx| {
1931 action_log
1932 .reject_edits_in_ranges(
1933 buffer.clone(),
1934 vec![Anchor::MIN..Anchor::MAX],
1935 cx,
1936 )
1937 .detach_and_log_err(cx);
1938 })
1939 }
1940 }),
1941 )
1942 .child(
1943 Button::new("keep-file", "Keep")
1944 .label_size(LabelSize::Small)
1945 .disabled(pending_edits)
1946 .on_click({
1947 let buffer = buffer.clone();
1948 let action_log = action_log.clone();
1949 move |_, _, cx| {
1950 action_log.update(cx, |action_log, cx| {
1951 action_log.keep_edits_in_range(
1952 buffer.clone(),
1953 Anchor::MIN..Anchor::MAX,
1954 cx,
1955 );
1956 })
1957 }
1958 }),
1959 ),
1960 )
1961 .child(
1962 div()
1963 .id("gradient-overlay")
1964 .absolute()
1965 .h_full()
1966 .w_12()
1967 .top_0()
1968 .bottom_0()
1969 .right(px(152.))
1970 .bg(overlay_gradient),
1971 );
1972
1973 Some(element)
1974 },
1975 ))
1976 }
1977
1978 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
1979 let focus_handle = self.message_editor.focus_handle(cx);
1980 let editor_bg_color = cx.theme().colors().editor_background;
1981 let (expand_icon, expand_tooltip) = if self.editor_expanded {
1982 (IconName::Minimize, "Minimize Message Editor")
1983 } else {
1984 (IconName::Maximize, "Expand Message Editor")
1985 };
1986
1987 v_flex()
1988 .on_action(cx.listener(Self::expand_message_editor))
1989 .p_2()
1990 .gap_2()
1991 .border_t_1()
1992 .border_color(cx.theme().colors().border)
1993 .bg(editor_bg_color)
1994 .when(self.editor_expanded, |this| {
1995 this.h(vh(0.8, window)).size_full().justify_between()
1996 })
1997 .child(
1998 v_flex()
1999 .relative()
2000 .size_full()
2001 .pt_1()
2002 .pr_2p5()
2003 .child(div().flex_1().child({
2004 let settings = ThemeSettings::get_global(cx);
2005 let font_size = TextSize::Small
2006 .rems(cx)
2007 .to_pixels(settings.agent_font_size(cx));
2008 let line_height = settings.buffer_line_height.value() * font_size;
2009
2010 let text_style = TextStyle {
2011 color: cx.theme().colors().text,
2012 font_family: settings.buffer_font.family.clone(),
2013 font_fallbacks: settings.buffer_font.fallbacks.clone(),
2014 font_features: settings.buffer_font.features.clone(),
2015 font_size: font_size.into(),
2016 line_height: line_height.into(),
2017 ..Default::default()
2018 };
2019
2020 EditorElement::new(
2021 &self.message_editor,
2022 EditorStyle {
2023 background: editor_bg_color,
2024 local_player: cx.theme().players().local(),
2025 text: text_style,
2026 syntax: cx.theme().syntax().clone(),
2027 ..Default::default()
2028 },
2029 )
2030 }))
2031 .child(
2032 h_flex()
2033 .absolute()
2034 .top_0()
2035 .right_0()
2036 .opacity(0.5)
2037 .hover(|this| this.opacity(1.0))
2038 .child(
2039 IconButton::new("toggle-height", expand_icon)
2040 .icon_size(IconSize::XSmall)
2041 .icon_color(Color::Muted)
2042 .tooltip({
2043 let focus_handle = focus_handle.clone();
2044 move |window, cx| {
2045 Tooltip::for_action_in(
2046 expand_tooltip,
2047 &ExpandMessageEditor,
2048 &focus_handle,
2049 window,
2050 cx,
2051 )
2052 }
2053 })
2054 .on_click(cx.listener(|_, _, window, cx| {
2055 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
2056 })),
2057 ),
2058 ),
2059 )
2060 .child(
2061 h_flex()
2062 .flex_none()
2063 .justify_between()
2064 .child(self.render_follow_toggle(cx))
2065 .child(self.render_send_button(cx)),
2066 )
2067 .into_any()
2068 }
2069
2070 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
2071 if self.thread().map_or(true, |thread| {
2072 thread.read(cx).status() == ThreadStatus::Idle
2073 }) {
2074 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
2075 IconButton::new("send-message", IconName::Send)
2076 .icon_color(Color::Accent)
2077 .style(ButtonStyle::Filled)
2078 .disabled(self.thread().is_none() || is_editor_empty)
2079 .on_click(cx.listener(|this, _, window, cx| {
2080 this.chat(&Chat, window, cx);
2081 }))
2082 .when(!is_editor_empty, |button| {
2083 button.tooltip(move |window, cx| Tooltip::for_action("Send", &Chat, window, cx))
2084 })
2085 .when(is_editor_empty, |button| {
2086 button.tooltip(Tooltip::text("Type a message to submit"))
2087 })
2088 .into_any_element()
2089 } else {
2090 IconButton::new("stop-generation", IconName::StopFilled)
2091 .icon_color(Color::Error)
2092 .style(ButtonStyle::Tinted(ui::TintColor::Error))
2093 .tooltip(move |window, cx| {
2094 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
2095 })
2096 .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
2097 .into_any_element()
2098 }
2099 }
2100
2101 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2102 let following = self
2103 .workspace
2104 .read_with(cx, |workspace, _| {
2105 workspace.is_being_followed(CollaboratorId::Agent)
2106 })
2107 .unwrap_or(false);
2108
2109 IconButton::new("follow-agent", IconName::Crosshair)
2110 .icon_size(IconSize::Small)
2111 .icon_color(Color::Muted)
2112 .toggle_state(following)
2113 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
2114 .tooltip(move |window, cx| {
2115 if following {
2116 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
2117 } else {
2118 Tooltip::with_meta(
2119 "Follow Agent",
2120 Some(&Follow),
2121 "Track the agent's location as it reads and edits files.",
2122 window,
2123 cx,
2124 )
2125 }
2126 })
2127 .on_click(cx.listener(move |this, _, window, cx| {
2128 this.workspace
2129 .update(cx, |workspace, cx| {
2130 if following {
2131 workspace.unfollow(CollaboratorId::Agent, window, cx);
2132 } else {
2133 workspace.follow(CollaboratorId::Agent, window, cx);
2134 }
2135 })
2136 .ok();
2137 }))
2138 }
2139
2140 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2141 let workspace = self.workspace.clone();
2142 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2143 Self::open_link(text, &workspace, window, cx);
2144 })
2145 }
2146
2147 fn open_link(
2148 url: SharedString,
2149 workspace: &WeakEntity<Workspace>,
2150 window: &mut Window,
2151 cx: &mut App,
2152 ) {
2153 let Some(workspace) = workspace.upgrade() else {
2154 cx.open_url(&url);
2155 return;
2156 };
2157
2158 if let Some(mention_path) = MentionPath::try_parse(&url) {
2159 workspace.update(cx, |workspace, cx| {
2160 let project = workspace.project();
2161 let Some((path, entry)) = project.update(cx, |project, cx| {
2162 let path = project.find_project_path(mention_path.path(), cx)?;
2163 let entry = project.entry_for_path(&path, cx)?;
2164 Some((path, entry))
2165 }) else {
2166 return;
2167 };
2168
2169 if entry.is_dir() {
2170 project.update(cx, |_, cx| {
2171 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2172 });
2173 } else {
2174 workspace
2175 .open_path(path, None, true, window, cx)
2176 .detach_and_log_err(cx);
2177 }
2178 })
2179 } else {
2180 cx.open_url(&url);
2181 }
2182 }
2183
2184 fn open_tool_call_location(
2185 &self,
2186 entry_ix: usize,
2187 location_ix: usize,
2188 window: &mut Window,
2189 cx: &mut Context<Self>,
2190 ) -> Option<()> {
2191 let location = self
2192 .thread()?
2193 .read(cx)
2194 .entries()
2195 .get(entry_ix)?
2196 .locations()?
2197 .get(location_ix)?;
2198
2199 let project_path = self
2200 .project
2201 .read(cx)
2202 .find_project_path(&location.path, cx)?;
2203
2204 let open_task = self
2205 .workspace
2206 .update(cx, |worskpace, cx| {
2207 worskpace.open_path(project_path, None, true, window, cx)
2208 })
2209 .log_err()?;
2210
2211 window
2212 .spawn(cx, async move |cx| {
2213 let item = open_task.await?;
2214
2215 let Some(active_editor) = item.downcast::<Editor>() else {
2216 return anyhow::Ok(());
2217 };
2218
2219 active_editor.update_in(cx, |editor, window, cx| {
2220 let snapshot = editor.buffer().read(cx).snapshot(cx);
2221 let first_hunk = editor
2222 .diff_hunks_in_ranges(
2223 &[editor::Anchor::min()..editor::Anchor::max()],
2224 &snapshot,
2225 )
2226 .next();
2227 if let Some(first_hunk) = first_hunk {
2228 let first_hunk_start = first_hunk.multi_buffer_range().start;
2229 editor.change_selections(Default::default(), window, cx, |selections| {
2230 selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
2231 })
2232 }
2233 })?;
2234
2235 anyhow::Ok(())
2236 })
2237 .detach_and_log_err(cx);
2238
2239 None
2240 }
2241
2242 pub fn open_thread_as_markdown(
2243 &self,
2244 workspace: Entity<Workspace>,
2245 window: &mut Window,
2246 cx: &mut App,
2247 ) -> Task<anyhow::Result<()>> {
2248 let markdown_language_task = workspace
2249 .read(cx)
2250 .app_state()
2251 .languages
2252 .language_for_name("Markdown");
2253
2254 let (thread_summary, markdown) = match &self.thread_state {
2255 ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
2256 let thread = thread.read(cx);
2257 (thread.title().to_string(), thread.to_markdown(cx))
2258 }
2259 ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())),
2260 };
2261
2262 window.spawn(cx, async move |cx| {
2263 let markdown_language = markdown_language_task.await?;
2264
2265 workspace.update_in(cx, |workspace, window, cx| {
2266 let project = workspace.project().clone();
2267
2268 if !project.read(cx).is_local() {
2269 anyhow::bail!("failed to open active thread as markdown in remote project");
2270 }
2271
2272 let buffer = project.update(cx, |project, cx| {
2273 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2274 });
2275 let buffer = cx.new(|cx| {
2276 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2277 });
2278
2279 workspace.add_item_to_active_pane(
2280 Box::new(cx.new(|cx| {
2281 let mut editor =
2282 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2283 editor.set_breadcrumb_header(thread_summary);
2284 editor
2285 })),
2286 None,
2287 true,
2288 window,
2289 cx,
2290 );
2291
2292 anyhow::Ok(())
2293 })??;
2294 anyhow::Ok(())
2295 })
2296 }
2297
2298 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
2299 self.list_state.scroll_to(ListOffset::default());
2300 cx.notify();
2301 }
2302}
2303
2304impl Focusable for AcpThreadView {
2305 fn focus_handle(&self, cx: &App) -> FocusHandle {
2306 self.message_editor.focus_handle(cx)
2307 }
2308}
2309
2310impl Render for AcpThreadView {
2311 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2312 let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText)
2313 .icon_size(IconSize::XSmall)
2314 .icon_color(Color::Ignored)
2315 .tooltip(Tooltip::text("Open Thread as Markdown"))
2316 .on_click(cx.listener(move |this, _, window, cx| {
2317 if let Some(workspace) = this.workspace.upgrade() {
2318 this.open_thread_as_markdown(workspace, window, cx)
2319 .detach_and_log_err(cx);
2320 }
2321 }));
2322
2323 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt)
2324 .icon_size(IconSize::XSmall)
2325 .icon_color(Color::Ignored)
2326 .tooltip(Tooltip::text("Scroll To Top"))
2327 .on_click(cx.listener(move |this, _, _, cx| {
2328 this.scroll_to_top(cx);
2329 }));
2330
2331 v_flex()
2332 .size_full()
2333 .key_context("AcpThread")
2334 .on_action(cx.listener(Self::chat))
2335 .on_action(cx.listener(Self::previous_history_message))
2336 .on_action(cx.listener(Self::next_history_message))
2337 .on_action(cx.listener(Self::open_agent_diff))
2338 .child(match &self.thread_state {
2339 ThreadState::Unauthenticated { .. } => {
2340 v_flex()
2341 .p_2()
2342 .flex_1()
2343 .items_center()
2344 .justify_center()
2345 .child(self.render_pending_auth_state())
2346 .child(
2347 h_flex().mt_1p5().justify_center().child(
2348 Button::new("sign-in", format!("Sign in to {}", self.agent.name()))
2349 .on_click(cx.listener(|this, _, window, cx| {
2350 this.authenticate(window, cx)
2351 })),
2352 ),
2353 )
2354 }
2355 ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
2356 ThreadState::LoadError(e) => v_flex()
2357 .p_2()
2358 .flex_1()
2359 .items_center()
2360 .justify_center()
2361 .child(self.render_error_state(e, cx)),
2362 ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| {
2363 if self.list_state.item_count() > 0 {
2364 this.child(
2365 list(self.list_state.clone())
2366 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
2367 .flex_grow()
2368 .into_any(),
2369 )
2370 .child(
2371 h_flex()
2372 .group("controls")
2373 .mt_1()
2374 .mr_1()
2375 .py_2()
2376 .px(RESPONSE_PADDING_X)
2377 .opacity(0.4)
2378 .hover(|style| style.opacity(1.))
2379 .flex_wrap()
2380 .justify_end()
2381 .child(open_as_markdown)
2382 .child(scroll_to_top)
2383 .into_any_element(),
2384 )
2385 .children(match thread.read(cx).status() {
2386 ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None,
2387 ThreadStatus::Generating => div()
2388 .px_5()
2389 .py_2()
2390 .child(LoadingLabel::new("").size(LabelSize::Small))
2391 .into(),
2392 })
2393 .children(self.render_activity_bar(&thread, window, cx))
2394 } else {
2395 this.child(self.render_empty_state(cx))
2396 }
2397 }),
2398 })
2399 .when_some(self.last_error.clone(), |el, error| {
2400 el.child(
2401 div()
2402 .p_2()
2403 .text_xs()
2404 .border_t_1()
2405 .border_color(cx.theme().colors().border)
2406 .bg(cx.theme().status().error_background)
2407 .child(
2408 self.render_markdown(error, default_markdown_style(false, window, cx)),
2409 ),
2410 )
2411 })
2412 .child(self.render_message_editor(window, cx))
2413 }
2414}
2415
2416fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
2417 let mut style = default_markdown_style(false, window, cx);
2418 let mut text_style = window.text_style();
2419 let theme_settings = ThemeSettings::get_global(cx);
2420
2421 let buffer_font = theme_settings.buffer_font.family.clone();
2422 let buffer_font_size = TextSize::Small.rems(cx);
2423
2424 text_style.refine(&TextStyleRefinement {
2425 font_family: Some(buffer_font),
2426 font_size: Some(buffer_font_size.into()),
2427 ..Default::default()
2428 });
2429
2430 style.base_text_style = text_style;
2431 style.link_callback = Some(Rc::new(move |url, cx| {
2432 if MentionPath::try_parse(url).is_some() {
2433 let colors = cx.theme().colors();
2434 Some(TextStyleRefinement {
2435 background_color: Some(colors.element_background),
2436 ..Default::default()
2437 })
2438 } else {
2439 None
2440 }
2441 }));
2442 style
2443}
2444
2445fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
2446 let theme_settings = ThemeSettings::get_global(cx);
2447 let colors = cx.theme().colors();
2448
2449 let buffer_font_size = TextSize::Small.rems(cx);
2450
2451 let mut text_style = window.text_style();
2452 let line_height = buffer_font_size * 1.75;
2453
2454 let font_family = if buffer_font {
2455 theme_settings.buffer_font.family.clone()
2456 } else {
2457 theme_settings.ui_font.family.clone()
2458 };
2459
2460 let font_size = if buffer_font {
2461 TextSize::Small.rems(cx)
2462 } else {
2463 TextSize::Default.rems(cx)
2464 };
2465
2466 text_style.refine(&TextStyleRefinement {
2467 font_family: Some(font_family),
2468 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
2469 font_features: Some(theme_settings.ui_font.features.clone()),
2470 font_size: Some(font_size.into()),
2471 line_height: Some(line_height.into()),
2472 color: Some(cx.theme().colors().text),
2473 ..Default::default()
2474 });
2475
2476 MarkdownStyle {
2477 base_text_style: text_style.clone(),
2478 syntax: cx.theme().syntax().clone(),
2479 selection_background_color: cx.theme().colors().element_selection_background,
2480 code_block_overflow_x_scroll: true,
2481 table_overflow_x_scroll: true,
2482 heading_level_styles: Some(HeadingLevelStyles {
2483 h1: Some(TextStyleRefinement {
2484 font_size: Some(rems(1.15).into()),
2485 ..Default::default()
2486 }),
2487 h2: Some(TextStyleRefinement {
2488 font_size: Some(rems(1.1).into()),
2489 ..Default::default()
2490 }),
2491 h3: Some(TextStyleRefinement {
2492 font_size: Some(rems(1.05).into()),
2493 ..Default::default()
2494 }),
2495 h4: Some(TextStyleRefinement {
2496 font_size: Some(rems(1.).into()),
2497 ..Default::default()
2498 }),
2499 h5: Some(TextStyleRefinement {
2500 font_size: Some(rems(0.95).into()),
2501 ..Default::default()
2502 }),
2503 h6: Some(TextStyleRefinement {
2504 font_size: Some(rems(0.875).into()),
2505 ..Default::default()
2506 }),
2507 }),
2508 code_block: StyleRefinement {
2509 padding: EdgesRefinement {
2510 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2511 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2512 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2513 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2514 },
2515 margin: EdgesRefinement {
2516 top: Some(Length::Definite(Pixels(8.).into())),
2517 left: Some(Length::Definite(Pixels(0.).into())),
2518 right: Some(Length::Definite(Pixels(0.).into())),
2519 bottom: Some(Length::Definite(Pixels(12.).into())),
2520 },
2521 border_style: Some(BorderStyle::Solid),
2522 border_widths: EdgesRefinement {
2523 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
2524 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
2525 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
2526 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
2527 },
2528 border_color: Some(colors.border_variant),
2529 background: Some(colors.editor_background.into()),
2530 text: Some(TextStyleRefinement {
2531 font_family: Some(theme_settings.buffer_font.family.clone()),
2532 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
2533 font_features: Some(theme_settings.buffer_font.features.clone()),
2534 font_size: Some(buffer_font_size.into()),
2535 ..Default::default()
2536 }),
2537 ..Default::default()
2538 },
2539 inline_code: TextStyleRefinement {
2540 font_family: Some(theme_settings.buffer_font.family.clone()),
2541 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
2542 font_features: Some(theme_settings.buffer_font.features.clone()),
2543 font_size: Some(buffer_font_size.into()),
2544 background_color: Some(colors.editor_foreground.opacity(0.08)),
2545 ..Default::default()
2546 },
2547 link: TextStyleRefinement {
2548 background_color: Some(colors.editor_foreground.opacity(0.025)),
2549 underline: Some(UnderlineStyle {
2550 color: Some(colors.text_accent.opacity(0.5)),
2551 thickness: px(1.),
2552 ..Default::default()
2553 }),
2554 ..Default::default()
2555 },
2556 ..Default::default()
2557 }
2558}
2559
2560fn plan_label_markdown_style(
2561 status: &acp::PlanEntryStatus,
2562 window: &Window,
2563 cx: &App,
2564) -> MarkdownStyle {
2565 let default_md_style = default_markdown_style(false, window, cx);
2566
2567 MarkdownStyle {
2568 base_text_style: TextStyle {
2569 color: cx.theme().colors().text_muted,
2570 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2571 Some(gpui::StrikethroughStyle {
2572 thickness: px(1.),
2573 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2574 })
2575 } else {
2576 None
2577 },
2578 ..default_md_style.base_text_style
2579 },
2580 ..default_md_style
2581 }
2582}