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