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