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