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