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