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