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