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