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