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