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