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