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