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 = matches!(
1157 tool_call.status,
1158 ToolCallStatus::WaitingForConfirmation { .. }
1159 );
1160 let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit);
1161 let has_diff = tool_call
1162 .content
1163 .iter()
1164 .any(|content| matches!(content, ToolCallContent::Diff { .. }));
1165 let has_nonempty_diff = tool_call.content.iter().any(|content| match content {
1166 ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx),
1167 _ => false,
1168 });
1169 let is_collapsible =
1170 !tool_call.content.is_empty() && !needs_confirmation && !is_edit && !has_diff;
1171 let is_open = tool_call.content.is_empty()
1172 || needs_confirmation
1173 || has_nonempty_diff
1174 || self.expanded_tool_calls.contains(&tool_call.id);
1175
1176 let gradient_color = cx.theme().colors().panel_background;
1177 let gradient_overlay = {
1178 div()
1179 .absolute()
1180 .top_0()
1181 .right_0()
1182 .w_12()
1183 .h_full()
1184 .bg(linear_gradient(
1185 90.,
1186 linear_color_stop(gradient_color, 1.),
1187 linear_color_stop(gradient_color.opacity(0.2), 0.),
1188 ))
1189 };
1190
1191 v_flex()
1192 .when(needs_confirmation || is_edit || has_diff, |this| {
1193 this.rounded_lg()
1194 .border_1()
1195 .border_color(self.tool_card_border_color(cx))
1196 .bg(cx.theme().colors().editor_background)
1197 .overflow_hidden()
1198 })
1199 .child(
1200 h_flex()
1201 .id(header_id)
1202 .w_full()
1203 .gap_1()
1204 .justify_between()
1205 .map(|this| {
1206 if needs_confirmation || is_edit || has_diff {
1207 this.pl_2()
1208 .pr_1()
1209 .py_1()
1210 .rounded_t_md()
1211 .border_b_1()
1212 .border_color(self.tool_card_border_color(cx))
1213 .bg(self.tool_card_header_bg(cx))
1214 } else {
1215 this.opacity(0.8).hover(|style| style.opacity(1.))
1216 }
1217 })
1218 .child(
1219 h_flex()
1220 .group(&card_header_id)
1221 .relative()
1222 .w_full()
1223 .map(|this| {
1224 if tool_call.locations.len() == 1 {
1225 this.gap_0()
1226 } else {
1227 this.gap_1p5()
1228 }
1229 })
1230 .text_size(self.tool_name_font_size())
1231 .child(self.render_tool_call_icon(
1232 card_header_id,
1233 entry_ix,
1234 is_collapsible,
1235 is_open,
1236 tool_call,
1237 cx,
1238 ))
1239 .child(if tool_call.locations.len() == 1 {
1240 let name = tool_call.locations[0]
1241 .path
1242 .file_name()
1243 .unwrap_or_default()
1244 .display()
1245 .to_string();
1246
1247 h_flex()
1248 .id(("open-tool-call-location", entry_ix))
1249 .w_full()
1250 .max_w_full()
1251 .px_1p5()
1252 .rounded_sm()
1253 .overflow_x_scroll()
1254 .opacity(0.8)
1255 .hover(|label| {
1256 label.opacity(1.).bg(cx
1257 .theme()
1258 .colors()
1259 .element_hover
1260 .opacity(0.5))
1261 })
1262 .child(name)
1263 .tooltip(Tooltip::text("Jump to File"))
1264 .on_click(cx.listener(move |this, _, window, cx| {
1265 this.open_tool_call_location(entry_ix, 0, window, cx);
1266 }))
1267 .into_any_element()
1268 } else {
1269 h_flex()
1270 .id("non-card-label-container")
1271 .w_full()
1272 .relative()
1273 .overflow_hidden()
1274 .child(
1275 h_flex()
1276 .id("non-card-label")
1277 .pr_8()
1278 .w_full()
1279 .overflow_x_scroll()
1280 .child(self.render_markdown(
1281 tool_call.label.clone(),
1282 default_markdown_style(
1283 needs_confirmation || is_edit || has_diff,
1284 window,
1285 cx,
1286 ),
1287 )),
1288 )
1289 .child(gradient_overlay)
1290 .on_click(cx.listener({
1291 let id = tool_call.id.clone();
1292 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1293 if is_open {
1294 this.expanded_tool_calls.remove(&id);
1295 } else {
1296 this.expanded_tool_calls.insert(id.clone());
1297 }
1298 cx.notify();
1299 }
1300 }))
1301 .into_any()
1302 }),
1303 )
1304 .children(status_icon),
1305 )
1306 .when(is_open, |this| {
1307 this.child(
1308 v_flex()
1309 .text_xs()
1310 .when(is_collapsible, |this| {
1311 this.mt_1()
1312 .border_1()
1313 .border_color(self.tool_card_border_color(cx))
1314 .bg(cx.theme().colors().editor_background)
1315 .rounded_lg()
1316 })
1317 .map(|this| {
1318 if is_open {
1319 match &tool_call.status {
1320 ToolCallStatus::WaitingForConfirmation { options, .. } => this
1321 .children(tool_call.content.iter().map(|content| {
1322 div()
1323 .py_1p5()
1324 .child(
1325 self.render_tool_call_content(
1326 content, window, cx,
1327 ),
1328 )
1329 .into_any_element()
1330 }))
1331 .child(self.render_permission_buttons(
1332 options,
1333 entry_ix,
1334 tool_call.id.clone(),
1335 tool_call.content.is_empty(),
1336 cx,
1337 )),
1338 ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
1339 this.children(tool_call.content.iter().map(|content| {
1340 div()
1341 .py_1p5()
1342 .child(
1343 self.render_tool_call_content(
1344 content, window, cx,
1345 ),
1346 )
1347 .into_any_element()
1348 }))
1349 }
1350 ToolCallStatus::Rejected => this,
1351 }
1352 } else {
1353 this
1354 }
1355 }),
1356 )
1357 })
1358 }
1359
1360 fn render_tool_call_content(
1361 &self,
1362 content: &ToolCallContent,
1363 window: &Window,
1364 cx: &Context<Self>,
1365 ) -> AnyElement {
1366 match content {
1367 ToolCallContent::ContentBlock(content) => {
1368 if let Some(md) = content.markdown() {
1369 div()
1370 .p_2()
1371 .child(
1372 self.render_markdown(
1373 md.clone(),
1374 default_markdown_style(false, window, cx),
1375 ),
1376 )
1377 .into_any_element()
1378 } else {
1379 Empty.into_any_element()
1380 }
1381 }
1382 ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()),
1383 ToolCallContent::Terminal(terminal) => self.render_terminal(terminal),
1384 }
1385 }
1386
1387 fn render_permission_buttons(
1388 &self,
1389 options: &[acp::PermissionOption],
1390 entry_ix: usize,
1391 tool_call_id: acp::ToolCallId,
1392 empty_content: bool,
1393 cx: &Context<Self>,
1394 ) -> Div {
1395 h_flex()
1396 .p_1p5()
1397 .gap_1()
1398 .justify_end()
1399 .when(!empty_content, |this| {
1400 this.border_t_1()
1401 .border_color(self.tool_card_border_color(cx))
1402 })
1403 .children(options.iter().map(|option| {
1404 let option_id = SharedString::from(option.id.0.clone());
1405 Button::new((option_id, entry_ix), option.name.clone())
1406 .map(|this| match option.kind {
1407 acp::PermissionOptionKind::AllowOnce => {
1408 this.icon(IconName::Check).icon_color(Color::Success)
1409 }
1410 acp::PermissionOptionKind::AllowAlways => {
1411 this.icon(IconName::CheckDouble).icon_color(Color::Success)
1412 }
1413 acp::PermissionOptionKind::RejectOnce => {
1414 this.icon(IconName::Close).icon_color(Color::Error)
1415 }
1416 acp::PermissionOptionKind::RejectAlways => {
1417 this.icon(IconName::Close).icon_color(Color::Error)
1418 }
1419 })
1420 .icon_position(IconPosition::Start)
1421 .icon_size(IconSize::XSmall)
1422 .label_size(LabelSize::Small)
1423 .on_click(cx.listener({
1424 let tool_call_id = tool_call_id.clone();
1425 let option_id = option.id.clone();
1426 let option_kind = option.kind;
1427 move |this, _, _, cx| {
1428 this.authorize_tool_call(
1429 tool_call_id.clone(),
1430 option_id.clone(),
1431 option_kind,
1432 cx,
1433 );
1434 }
1435 }))
1436 }))
1437 }
1438
1439 fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
1440 v_flex()
1441 .h_full()
1442 .child(
1443 if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) {
1444 editor.clone().into_any_element()
1445 } else {
1446 Empty.into_any()
1447 },
1448 )
1449 .into_any()
1450 }
1451
1452 fn render_terminal(&self, terminal: &Entity<acp_thread::Terminal>) -> AnyElement {
1453 v_flex()
1454 .h_72()
1455 .child(
1456 if let Some(terminal_view) = self.terminal_views.get(&terminal.entity_id()) {
1457 // TODO: terminal has all the state we need to reproduce
1458 // what we had in the terminal card.
1459 terminal_view.clone().into_any_element()
1460 } else {
1461 Empty.into_any()
1462 },
1463 )
1464 .into_any()
1465 }
1466
1467 fn render_agent_logo(&self) -> AnyElement {
1468 Icon::new(self.agent.logo())
1469 .color(Color::Muted)
1470 .size(IconSize::XLarge)
1471 .into_any_element()
1472 }
1473
1474 fn render_error_agent_logo(&self) -> AnyElement {
1475 let logo = Icon::new(self.agent.logo())
1476 .color(Color::Muted)
1477 .size(IconSize::XLarge)
1478 .into_any_element();
1479
1480 h_flex()
1481 .relative()
1482 .justify_center()
1483 .child(div().opacity(0.3).child(logo))
1484 .child(
1485 h_flex().absolute().right_1().bottom_0().child(
1486 Icon::new(IconName::XCircle)
1487 .color(Color::Error)
1488 .size(IconSize::Small),
1489 ),
1490 )
1491 .into_any_element()
1492 }
1493
1494 fn render_empty_state(&self, cx: &App) -> AnyElement {
1495 let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
1496
1497 v_flex()
1498 .size_full()
1499 .items_center()
1500 .justify_center()
1501 .child(if loading {
1502 h_flex()
1503 .justify_center()
1504 .child(self.render_agent_logo())
1505 .with_animation(
1506 "pulsating_icon",
1507 Animation::new(Duration::from_secs(2))
1508 .repeat()
1509 .with_easing(pulsating_between(0.4, 1.0)),
1510 |icon, delta| icon.opacity(delta),
1511 )
1512 .into_any()
1513 } else {
1514 self.render_agent_logo().into_any_element()
1515 })
1516 .child(h_flex().mt_4().mb_1().justify_center().child(if loading {
1517 div()
1518 .child(LoadingLabel::new("").size(LabelSize::Large))
1519 .into_any_element()
1520 } else {
1521 Headline::new(self.agent.empty_state_headline())
1522 .size(HeadlineSize::Medium)
1523 .into_any_element()
1524 }))
1525 .child(
1526 div()
1527 .max_w_1_2()
1528 .text_sm()
1529 .text_center()
1530 .map(|this| {
1531 if loading {
1532 this.invisible()
1533 } else {
1534 this.text_color(cx.theme().colors().text_muted)
1535 }
1536 })
1537 .child(self.agent.empty_state_message()),
1538 )
1539 .into_any()
1540 }
1541
1542 fn render_pending_auth_state(&self) -> AnyElement {
1543 v_flex()
1544 .items_center()
1545 .justify_center()
1546 .child(self.render_error_agent_logo())
1547 .child(
1548 h_flex()
1549 .mt_4()
1550 .mb_1()
1551 .justify_center()
1552 .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
1553 )
1554 .into_any()
1555 }
1556
1557 fn render_server_exited(&self, status: ExitStatus, _cx: &Context<Self>) -> AnyElement {
1558 v_flex()
1559 .items_center()
1560 .justify_center()
1561 .child(self.render_error_agent_logo())
1562 .child(
1563 v_flex()
1564 .mt_4()
1565 .mb_2()
1566 .gap_0p5()
1567 .text_center()
1568 .items_center()
1569 .child(Headline::new("Server exited unexpectedly").size(HeadlineSize::Medium))
1570 .child(
1571 Label::new(format!("Exit status: {}", status.code().unwrap_or(-127)))
1572 .size(LabelSize::Small)
1573 .color(Color::Muted),
1574 ),
1575 )
1576 .into_any_element()
1577 }
1578
1579 fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1580 let mut container = v_flex()
1581 .items_center()
1582 .justify_center()
1583 .child(self.render_error_agent_logo())
1584 .child(
1585 v_flex()
1586 .mt_4()
1587 .mb_2()
1588 .gap_0p5()
1589 .text_center()
1590 .items_center()
1591 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1592 .child(
1593 Label::new(e.to_string())
1594 .size(LabelSize::Small)
1595 .color(Color::Muted),
1596 ),
1597 );
1598
1599 if let LoadError::Unsupported {
1600 upgrade_message,
1601 upgrade_command,
1602 ..
1603 } = &e
1604 {
1605 let upgrade_message = upgrade_message.clone();
1606 let upgrade_command = upgrade_command.clone();
1607 container = container.child(Button::new("upgrade", upgrade_message).on_click(
1608 cx.listener(move |this, _, window, cx| {
1609 this.workspace
1610 .update(cx, |workspace, cx| {
1611 let project = workspace.project().read(cx);
1612 let cwd = project.first_project_directory(cx);
1613 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1614 let spawn_in_terminal = task::SpawnInTerminal {
1615 id: task::TaskId("install".to_string()),
1616 full_label: upgrade_command.clone(),
1617 label: upgrade_command.clone(),
1618 command: Some(upgrade_command.clone()),
1619 args: Vec::new(),
1620 command_label: upgrade_command.clone(),
1621 cwd,
1622 env: Default::default(),
1623 use_new_terminal: true,
1624 allow_concurrent_runs: true,
1625 reveal: Default::default(),
1626 reveal_target: Default::default(),
1627 hide: Default::default(),
1628 shell,
1629 show_summary: true,
1630 show_command: true,
1631 show_rerun: false,
1632 };
1633 workspace
1634 .spawn_in_terminal(spawn_in_terminal, window, cx)
1635 .detach();
1636 })
1637 .ok();
1638 }),
1639 ));
1640 }
1641
1642 container.into_any()
1643 }
1644
1645 fn render_activity_bar(
1646 &self,
1647 thread_entity: &Entity<AcpThread>,
1648 window: &mut Window,
1649 cx: &Context<Self>,
1650 ) -> Option<AnyElement> {
1651 let thread = thread_entity.read(cx);
1652 let action_log = thread.action_log();
1653 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1654 let plan = thread.plan();
1655
1656 if changed_buffers.is_empty() && plan.is_empty() {
1657 return None;
1658 }
1659
1660 let editor_bg_color = cx.theme().colors().editor_background;
1661 let active_color = cx.theme().colors().element_selected;
1662 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
1663
1664 let pending_edits = thread.has_pending_edit_tool_calls();
1665
1666 v_flex()
1667 .mt_1()
1668 .mx_2()
1669 .bg(bg_edit_files_disclosure)
1670 .border_1()
1671 .border_b_0()
1672 .border_color(cx.theme().colors().border)
1673 .rounded_t_md()
1674 .shadow(vec![gpui::BoxShadow {
1675 color: gpui::black().opacity(0.15),
1676 offset: point(px(1.), px(-1.)),
1677 blur_radius: px(3.),
1678 spread_radius: px(0.),
1679 }])
1680 .when(!plan.is_empty(), |this| {
1681 this.child(self.render_plan_summary(plan, window, cx))
1682 .when(self.plan_expanded, |parent| {
1683 parent.child(self.render_plan_entries(plan, window, cx))
1684 })
1685 })
1686 .when(!changed_buffers.is_empty(), |this| {
1687 this.child(Divider::horizontal().color(DividerColor::Border))
1688 .child(self.render_edits_summary(
1689 action_log,
1690 &changed_buffers,
1691 self.edits_expanded,
1692 pending_edits,
1693 window,
1694 cx,
1695 ))
1696 .when(self.edits_expanded, |parent| {
1697 parent.child(self.render_edited_files(
1698 action_log,
1699 &changed_buffers,
1700 pending_edits,
1701 cx,
1702 ))
1703 })
1704 })
1705 .into_any()
1706 .into()
1707 }
1708
1709 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
1710 let stats = plan.stats();
1711
1712 let title = if let Some(entry) = stats.in_progress_entry
1713 && !self.plan_expanded
1714 {
1715 h_flex()
1716 .w_full()
1717 .cursor_default()
1718 .gap_1()
1719 .text_xs()
1720 .text_color(cx.theme().colors().text_muted)
1721 .justify_between()
1722 .child(
1723 h_flex()
1724 .gap_1()
1725 .child(
1726 Label::new("Current:")
1727 .size(LabelSize::Small)
1728 .color(Color::Muted),
1729 )
1730 .child(MarkdownElement::new(
1731 entry.content.clone(),
1732 plan_label_markdown_style(&entry.status, window, cx),
1733 )),
1734 )
1735 .when(stats.pending > 0, |this| {
1736 this.child(
1737 Label::new(format!("{} left", stats.pending))
1738 .size(LabelSize::Small)
1739 .color(Color::Muted)
1740 .mr_1(),
1741 )
1742 })
1743 } else {
1744 let status_label = if stats.pending == 0 {
1745 "All Done".to_string()
1746 } else if stats.completed == 0 {
1747 format!("{} Tasks", plan.entries.len())
1748 } else {
1749 format!("{}/{}", stats.completed, plan.entries.len())
1750 };
1751
1752 h_flex()
1753 .w_full()
1754 .gap_1()
1755 .justify_between()
1756 .child(
1757 Label::new("Plan")
1758 .size(LabelSize::Small)
1759 .color(Color::Muted),
1760 )
1761 .child(
1762 Label::new(status_label)
1763 .size(LabelSize::Small)
1764 .color(Color::Muted)
1765 .mr_1(),
1766 )
1767 };
1768
1769 h_flex()
1770 .p_1()
1771 .justify_between()
1772 .when(self.plan_expanded, |this| {
1773 this.border_b_1().border_color(cx.theme().colors().border)
1774 })
1775 .child(
1776 h_flex()
1777 .id("plan_summary")
1778 .w_full()
1779 .gap_1()
1780 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
1781 .child(title)
1782 .on_click(cx.listener(|this, _, _, cx| {
1783 this.plan_expanded = !this.plan_expanded;
1784 cx.notify();
1785 })),
1786 )
1787 }
1788
1789 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
1790 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
1791 let element = h_flex()
1792 .py_1()
1793 .px_2()
1794 .gap_2()
1795 .justify_between()
1796 .bg(cx.theme().colors().editor_background)
1797 .when(index < plan.entries.len() - 1, |parent| {
1798 parent.border_color(cx.theme().colors().border).border_b_1()
1799 })
1800 .child(
1801 h_flex()
1802 .id(("plan_entry", index))
1803 .gap_1p5()
1804 .max_w_full()
1805 .overflow_x_scroll()
1806 .text_xs()
1807 .text_color(cx.theme().colors().text_muted)
1808 .child(match entry.status {
1809 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
1810 .size(IconSize::Small)
1811 .color(Color::Muted)
1812 .into_any_element(),
1813 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
1814 .size(IconSize::Small)
1815 .color(Color::Accent)
1816 .with_animation(
1817 "running",
1818 Animation::new(Duration::from_secs(2)).repeat(),
1819 |icon, delta| {
1820 icon.transform(Transformation::rotate(percentage(delta)))
1821 },
1822 )
1823 .into_any_element(),
1824 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
1825 .size(IconSize::Small)
1826 .color(Color::Success)
1827 .into_any_element(),
1828 })
1829 .child(MarkdownElement::new(
1830 entry.content.clone(),
1831 plan_label_markdown_style(&entry.status, window, cx),
1832 )),
1833 );
1834
1835 Some(element)
1836 }))
1837 }
1838
1839 fn render_edits_summary(
1840 &self,
1841 action_log: &Entity<ActionLog>,
1842 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1843 expanded: bool,
1844 pending_edits: bool,
1845 window: &mut Window,
1846 cx: &Context<Self>,
1847 ) -> Div {
1848 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
1849
1850 let focus_handle = self.focus_handle(cx);
1851
1852 h_flex()
1853 .p_1()
1854 .justify_between()
1855 .when(expanded, |this| {
1856 this.border_b_1().border_color(cx.theme().colors().border)
1857 })
1858 .child(
1859 h_flex()
1860 .id("edits-container")
1861 .w_full()
1862 .gap_1()
1863 .child(Disclosure::new("edits-disclosure", expanded))
1864 .map(|this| {
1865 if pending_edits {
1866 this.child(
1867 Label::new(format!(
1868 "Editing {} {}…",
1869 changed_buffers.len(),
1870 if changed_buffers.len() == 1 {
1871 "file"
1872 } else {
1873 "files"
1874 }
1875 ))
1876 .color(Color::Muted)
1877 .size(LabelSize::Small)
1878 .with_animation(
1879 "edit-label",
1880 Animation::new(Duration::from_secs(2))
1881 .repeat()
1882 .with_easing(pulsating_between(0.3, 0.7)),
1883 |label, delta| label.alpha(delta),
1884 ),
1885 )
1886 } else {
1887 this.child(
1888 Label::new("Edits")
1889 .size(LabelSize::Small)
1890 .color(Color::Muted),
1891 )
1892 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
1893 .child(
1894 Label::new(format!(
1895 "{} {}",
1896 changed_buffers.len(),
1897 if changed_buffers.len() == 1 {
1898 "file"
1899 } else {
1900 "files"
1901 }
1902 ))
1903 .size(LabelSize::Small)
1904 .color(Color::Muted),
1905 )
1906 }
1907 })
1908 .on_click(cx.listener(|this, _, _, cx| {
1909 this.edits_expanded = !this.edits_expanded;
1910 cx.notify();
1911 })),
1912 )
1913 .child(
1914 h_flex()
1915 .gap_1()
1916 .child(
1917 IconButton::new("review-changes", IconName::ListTodo)
1918 .icon_size(IconSize::Small)
1919 .tooltip({
1920 let focus_handle = focus_handle.clone();
1921 move |window, cx| {
1922 Tooltip::for_action_in(
1923 "Review Changes",
1924 &OpenAgentDiff,
1925 &focus_handle,
1926 window,
1927 cx,
1928 )
1929 }
1930 })
1931 .on_click(cx.listener(|_, _, window, cx| {
1932 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
1933 })),
1934 )
1935 .child(Divider::vertical().color(DividerColor::Border))
1936 .child(
1937 Button::new("reject-all-changes", "Reject All")
1938 .label_size(LabelSize::Small)
1939 .disabled(pending_edits)
1940 .when(pending_edits, |this| {
1941 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
1942 })
1943 .key_binding(
1944 KeyBinding::for_action_in(
1945 &RejectAll,
1946 &focus_handle.clone(),
1947 window,
1948 cx,
1949 )
1950 .map(|kb| kb.size(rems_from_px(10.))),
1951 )
1952 .on_click({
1953 let action_log = action_log.clone();
1954 cx.listener(move |_, _, _, cx| {
1955 action_log.update(cx, |action_log, cx| {
1956 action_log.reject_all_edits(cx).detach();
1957 })
1958 })
1959 }),
1960 )
1961 .child(
1962 Button::new("keep-all-changes", "Keep All")
1963 .label_size(LabelSize::Small)
1964 .disabled(pending_edits)
1965 .when(pending_edits, |this| {
1966 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
1967 })
1968 .key_binding(
1969 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
1970 .map(|kb| kb.size(rems_from_px(10.))),
1971 )
1972 .on_click({
1973 let action_log = action_log.clone();
1974 cx.listener(move |_, _, _, cx| {
1975 action_log.update(cx, |action_log, cx| {
1976 action_log.keep_all_edits(cx);
1977 })
1978 })
1979 }),
1980 ),
1981 )
1982 }
1983
1984 fn render_edited_files(
1985 &self,
1986 action_log: &Entity<ActionLog>,
1987 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1988 pending_edits: bool,
1989 cx: &Context<Self>,
1990 ) -> Div {
1991 let editor_bg_color = cx.theme().colors().editor_background;
1992
1993 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
1994 |(index, (buffer, _diff))| {
1995 let file = buffer.read(cx).file()?;
1996 let path = file.path();
1997
1998 let file_path = path.parent().and_then(|parent| {
1999 let parent_str = parent.to_string_lossy();
2000
2001 if parent_str.is_empty() {
2002 None
2003 } else {
2004 Some(
2005 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
2006 .color(Color::Muted)
2007 .size(LabelSize::XSmall)
2008 .buffer_font(cx),
2009 )
2010 }
2011 });
2012
2013 let file_name = path.file_name().map(|name| {
2014 Label::new(name.to_string_lossy().to_string())
2015 .size(LabelSize::XSmall)
2016 .buffer_font(cx)
2017 });
2018
2019 let file_icon = FileIcons::get_icon(&path, cx)
2020 .map(Icon::from_path)
2021 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
2022 .unwrap_or_else(|| {
2023 Icon::new(IconName::File)
2024 .color(Color::Muted)
2025 .size(IconSize::Small)
2026 });
2027
2028 let overlay_gradient = linear_gradient(
2029 90.,
2030 linear_color_stop(editor_bg_color, 1.),
2031 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
2032 );
2033
2034 let element = h_flex()
2035 .group("edited-code")
2036 .id(("file-container", index))
2037 .relative()
2038 .py_1()
2039 .pl_2()
2040 .pr_1()
2041 .gap_2()
2042 .justify_between()
2043 .bg(editor_bg_color)
2044 .when(index < changed_buffers.len() - 1, |parent| {
2045 parent.border_color(cx.theme().colors().border).border_b_1()
2046 })
2047 .child(
2048 h_flex()
2049 .id(("file-name", index))
2050 .pr_8()
2051 .gap_1p5()
2052 .max_w_full()
2053 .overflow_x_scroll()
2054 .child(file_icon)
2055 .child(h_flex().gap_0p5().children(file_name).children(file_path))
2056 .on_click({
2057 let buffer = buffer.clone();
2058 cx.listener(move |this, _, window, cx| {
2059 this.open_edited_buffer(&buffer, window, cx);
2060 })
2061 }),
2062 )
2063 .child(
2064 h_flex()
2065 .gap_1()
2066 .visible_on_hover("edited-code")
2067 .child(
2068 Button::new("review", "Review")
2069 .label_size(LabelSize::Small)
2070 .on_click({
2071 let buffer = buffer.clone();
2072 cx.listener(move |this, _, window, cx| {
2073 this.open_edited_buffer(&buffer, window, cx);
2074 })
2075 }),
2076 )
2077 .child(Divider::vertical().color(DividerColor::BorderVariant))
2078 .child(
2079 Button::new("reject-file", "Reject")
2080 .label_size(LabelSize::Small)
2081 .disabled(pending_edits)
2082 .on_click({
2083 let buffer = buffer.clone();
2084 let action_log = action_log.clone();
2085 move |_, _, cx| {
2086 action_log.update(cx, |action_log, cx| {
2087 action_log
2088 .reject_edits_in_ranges(
2089 buffer.clone(),
2090 vec![Anchor::MIN..Anchor::MAX],
2091 cx,
2092 )
2093 .detach_and_log_err(cx);
2094 })
2095 }
2096 }),
2097 )
2098 .child(
2099 Button::new("keep-file", "Keep")
2100 .label_size(LabelSize::Small)
2101 .disabled(pending_edits)
2102 .on_click({
2103 let buffer = buffer.clone();
2104 let action_log = action_log.clone();
2105 move |_, _, cx| {
2106 action_log.update(cx, |action_log, cx| {
2107 action_log.keep_edits_in_range(
2108 buffer.clone(),
2109 Anchor::MIN..Anchor::MAX,
2110 cx,
2111 );
2112 })
2113 }
2114 }),
2115 ),
2116 )
2117 .child(
2118 div()
2119 .id("gradient-overlay")
2120 .absolute()
2121 .h_full()
2122 .w_12()
2123 .top_0()
2124 .bottom_0()
2125 .right(px(152.))
2126 .bg(overlay_gradient),
2127 );
2128
2129 Some(element)
2130 },
2131 ))
2132 }
2133
2134 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2135 let focus_handle = self.message_editor.focus_handle(cx);
2136 let editor_bg_color = cx.theme().colors().editor_background;
2137 let (expand_icon, expand_tooltip) = if self.editor_expanded {
2138 (IconName::Minimize, "Minimize Message Editor")
2139 } else {
2140 (IconName::Maximize, "Expand Message Editor")
2141 };
2142
2143 v_flex()
2144 .on_action(cx.listener(Self::expand_message_editor))
2145 .p_2()
2146 .gap_2()
2147 .border_t_1()
2148 .border_color(cx.theme().colors().border)
2149 .bg(editor_bg_color)
2150 .when(self.editor_expanded, |this| {
2151 this.h(vh(0.8, window)).size_full().justify_between()
2152 })
2153 .child(
2154 v_flex()
2155 .relative()
2156 .size_full()
2157 .pt_1()
2158 .pr_2p5()
2159 .child(div().flex_1().child({
2160 let settings = ThemeSettings::get_global(cx);
2161 let font_size = TextSize::Small
2162 .rems(cx)
2163 .to_pixels(settings.agent_font_size(cx));
2164 let line_height = settings.buffer_line_height.value() * font_size;
2165
2166 let text_style = TextStyle {
2167 color: cx.theme().colors().text,
2168 font_family: settings.buffer_font.family.clone(),
2169 font_fallbacks: settings.buffer_font.fallbacks.clone(),
2170 font_features: settings.buffer_font.features.clone(),
2171 font_size: font_size.into(),
2172 line_height: line_height.into(),
2173 ..Default::default()
2174 };
2175
2176 EditorElement::new(
2177 &self.message_editor,
2178 EditorStyle {
2179 background: editor_bg_color,
2180 local_player: cx.theme().players().local(),
2181 text: text_style,
2182 syntax: cx.theme().syntax().clone(),
2183 ..Default::default()
2184 },
2185 )
2186 }))
2187 .child(
2188 h_flex()
2189 .absolute()
2190 .top_0()
2191 .right_0()
2192 .opacity(0.5)
2193 .hover(|this| this.opacity(1.0))
2194 .child(
2195 IconButton::new("toggle-height", expand_icon)
2196 .icon_size(IconSize::Small)
2197 .icon_color(Color::Muted)
2198 .tooltip({
2199 let focus_handle = focus_handle.clone();
2200 move |window, cx| {
2201 Tooltip::for_action_in(
2202 expand_tooltip,
2203 &ExpandMessageEditor,
2204 &focus_handle,
2205 window,
2206 cx,
2207 )
2208 }
2209 })
2210 .on_click(cx.listener(|_, _, window, cx| {
2211 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
2212 })),
2213 ),
2214 ),
2215 )
2216 .child(
2217 h_flex()
2218 .flex_none()
2219 .justify_between()
2220 .child(self.render_follow_toggle(cx))
2221 .child(self.render_send_button(cx)),
2222 )
2223 .into_any()
2224 }
2225
2226 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
2227 if self.thread().map_or(true, |thread| {
2228 thread.read(cx).status() == ThreadStatus::Idle
2229 }) {
2230 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
2231 IconButton::new("send-message", IconName::Send)
2232 .icon_color(Color::Accent)
2233 .style(ButtonStyle::Filled)
2234 .disabled(self.thread().is_none() || is_editor_empty)
2235 .when(!is_editor_empty, |button| {
2236 button.tooltip(move |window, cx| Tooltip::for_action("Send", &Chat, window, cx))
2237 })
2238 .when(is_editor_empty, |button| {
2239 button.tooltip(Tooltip::text("Type a message to submit"))
2240 })
2241 .on_click(cx.listener(|this, _, window, cx| {
2242 this.chat(&Chat, window, cx);
2243 }))
2244 .into_any_element()
2245 } else {
2246 IconButton::new("stop-generation", IconName::Stop)
2247 .icon_color(Color::Error)
2248 .style(ButtonStyle::Tinted(ui::TintColor::Error))
2249 .tooltip(move |window, cx| {
2250 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
2251 })
2252 .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
2253 .into_any_element()
2254 }
2255 }
2256
2257 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2258 let following = self
2259 .workspace
2260 .read_with(cx, |workspace, _| {
2261 workspace.is_being_followed(CollaboratorId::Agent)
2262 })
2263 .unwrap_or(false);
2264
2265 IconButton::new("follow-agent", IconName::Crosshair)
2266 .icon_size(IconSize::Small)
2267 .icon_color(Color::Muted)
2268 .toggle_state(following)
2269 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
2270 .tooltip(move |window, cx| {
2271 if following {
2272 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
2273 } else {
2274 Tooltip::with_meta(
2275 "Follow Agent",
2276 Some(&Follow),
2277 "Track the agent's location as it reads and edits files.",
2278 window,
2279 cx,
2280 )
2281 }
2282 })
2283 .on_click(cx.listener(move |this, _, window, cx| {
2284 this.workspace
2285 .update(cx, |workspace, cx| {
2286 if following {
2287 workspace.unfollow(CollaboratorId::Agent, window, cx);
2288 } else {
2289 workspace.follow(CollaboratorId::Agent, window, cx);
2290 }
2291 })
2292 .ok();
2293 }))
2294 }
2295
2296 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2297 let workspace = self.workspace.clone();
2298 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2299 Self::open_link(text, &workspace, window, cx);
2300 })
2301 }
2302
2303 fn open_link(
2304 url: SharedString,
2305 workspace: &WeakEntity<Workspace>,
2306 window: &mut Window,
2307 cx: &mut App,
2308 ) {
2309 let Some(workspace) = workspace.upgrade() else {
2310 cx.open_url(&url);
2311 return;
2312 };
2313
2314 if let Some(mention_path) = MentionPath::try_parse(&url) {
2315 workspace.update(cx, |workspace, cx| {
2316 let project = workspace.project();
2317 let Some((path, entry)) = project.update(cx, |project, cx| {
2318 let path = project.find_project_path(mention_path.path(), cx)?;
2319 let entry = project.entry_for_path(&path, cx)?;
2320 Some((path, entry))
2321 }) else {
2322 return;
2323 };
2324
2325 if entry.is_dir() {
2326 project.update(cx, |_, cx| {
2327 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2328 });
2329 } else {
2330 workspace
2331 .open_path(path, None, true, window, cx)
2332 .detach_and_log_err(cx);
2333 }
2334 })
2335 } else {
2336 cx.open_url(&url);
2337 }
2338 }
2339
2340 fn open_tool_call_location(
2341 &self,
2342 entry_ix: usize,
2343 location_ix: usize,
2344 window: &mut Window,
2345 cx: &mut Context<Self>,
2346 ) -> Option<()> {
2347 let location = self
2348 .thread()?
2349 .read(cx)
2350 .entries()
2351 .get(entry_ix)?
2352 .locations()?
2353 .get(location_ix)?;
2354
2355 let project_path = self
2356 .project
2357 .read(cx)
2358 .find_project_path(&location.path, cx)?;
2359
2360 let open_task = self
2361 .workspace
2362 .update(cx, |worskpace, cx| {
2363 worskpace.open_path(project_path, None, true, window, cx)
2364 })
2365 .log_err()?;
2366
2367 window
2368 .spawn(cx, async move |cx| {
2369 let item = open_task.await?;
2370
2371 let Some(active_editor) = item.downcast::<Editor>() else {
2372 return anyhow::Ok(());
2373 };
2374
2375 active_editor.update_in(cx, |editor, window, cx| {
2376 let snapshot = editor.buffer().read(cx).snapshot(cx);
2377 let first_hunk = editor
2378 .diff_hunks_in_ranges(
2379 &[editor::Anchor::min()..editor::Anchor::max()],
2380 &snapshot,
2381 )
2382 .next();
2383 if let Some(first_hunk) = first_hunk {
2384 let first_hunk_start = first_hunk.multi_buffer_range().start;
2385 editor.change_selections(Default::default(), window, cx, |selections| {
2386 selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
2387 })
2388 }
2389 })?;
2390
2391 anyhow::Ok(())
2392 })
2393 .detach_and_log_err(cx);
2394
2395 None
2396 }
2397
2398 pub fn open_thread_as_markdown(
2399 &self,
2400 workspace: Entity<Workspace>,
2401 window: &mut Window,
2402 cx: &mut App,
2403 ) -> Task<anyhow::Result<()>> {
2404 let markdown_language_task = workspace
2405 .read(cx)
2406 .app_state()
2407 .languages
2408 .language_for_name("Markdown");
2409
2410 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
2411 let thread = thread.read(cx);
2412 (thread.title().to_string(), thread.to_markdown(cx))
2413 } else {
2414 return Task::ready(Ok(()));
2415 };
2416
2417 window.spawn(cx, async move |cx| {
2418 let markdown_language = markdown_language_task.await?;
2419
2420 workspace.update_in(cx, |workspace, window, cx| {
2421 let project = workspace.project().clone();
2422
2423 if !project.read(cx).is_local() {
2424 anyhow::bail!("failed to open active thread as markdown in remote project");
2425 }
2426
2427 let buffer = project.update(cx, |project, cx| {
2428 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2429 });
2430 let buffer = cx.new(|cx| {
2431 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2432 });
2433
2434 workspace.add_item_to_active_pane(
2435 Box::new(cx.new(|cx| {
2436 let mut editor =
2437 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2438 editor.set_breadcrumb_header(thread_summary);
2439 editor
2440 })),
2441 None,
2442 true,
2443 window,
2444 cx,
2445 );
2446
2447 anyhow::Ok(())
2448 })??;
2449 anyhow::Ok(())
2450 })
2451 }
2452
2453 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
2454 self.list_state.scroll_to(ListOffset::default());
2455 cx.notify();
2456 }
2457
2458 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
2459 if let Some(thread) = self.thread() {
2460 let entry_count = thread.read(cx).entries().len();
2461 self.list_state.reset(entry_count);
2462 cx.notify();
2463 }
2464 }
2465
2466 fn notify_with_sound(
2467 &mut self,
2468 caption: impl Into<SharedString>,
2469 icon: IconName,
2470 window: &mut Window,
2471 cx: &mut Context<Self>,
2472 ) {
2473 self.play_notification_sound(window, cx);
2474 self.show_notification(caption, icon, window, cx);
2475 }
2476
2477 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
2478 let settings = AgentSettings::get_global(cx);
2479 if settings.play_sound_when_agent_done && !window.is_window_active() {
2480 Audio::play_sound(Sound::AgentDone, cx);
2481 }
2482 }
2483
2484 fn show_notification(
2485 &mut self,
2486 caption: impl Into<SharedString>,
2487 icon: IconName,
2488 window: &mut Window,
2489 cx: &mut Context<Self>,
2490 ) {
2491 if window.is_window_active() || !self.notifications.is_empty() {
2492 return;
2493 }
2494
2495 let title = self.title(cx);
2496
2497 match AgentSettings::get_global(cx).notify_when_agent_waiting {
2498 NotifyWhenAgentWaiting::PrimaryScreen => {
2499 if let Some(primary) = cx.primary_display() {
2500 self.pop_up(icon, caption.into(), title, window, primary, cx);
2501 }
2502 }
2503 NotifyWhenAgentWaiting::AllScreens => {
2504 let caption = caption.into();
2505 for screen in cx.displays() {
2506 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
2507 }
2508 }
2509 NotifyWhenAgentWaiting::Never => {
2510 // Don't show anything
2511 }
2512 }
2513 }
2514
2515 fn pop_up(
2516 &mut self,
2517 icon: IconName,
2518 caption: SharedString,
2519 title: SharedString,
2520 window: &mut Window,
2521 screen: Rc<dyn PlatformDisplay>,
2522 cx: &mut Context<Self>,
2523 ) {
2524 let options = AgentNotification::window_options(screen, cx);
2525
2526 let project_name = self.workspace.upgrade().and_then(|workspace| {
2527 workspace
2528 .read(cx)
2529 .project()
2530 .read(cx)
2531 .visible_worktrees(cx)
2532 .next()
2533 .map(|worktree| worktree.read(cx).root_name().to_string())
2534 });
2535
2536 if let Some(screen_window) = cx
2537 .open_window(options, |_, cx| {
2538 cx.new(|_| {
2539 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
2540 })
2541 })
2542 .log_err()
2543 {
2544 if let Some(pop_up) = screen_window.entity(cx).log_err() {
2545 self.notification_subscriptions
2546 .entry(screen_window)
2547 .or_insert_with(Vec::new)
2548 .push(cx.subscribe_in(&pop_up, window, {
2549 |this, _, event, window, cx| match event {
2550 AgentNotificationEvent::Accepted => {
2551 let handle = window.window_handle();
2552 cx.activate(true);
2553
2554 let workspace_handle = this.workspace.clone();
2555
2556 // If there are multiple Zed windows, activate the correct one.
2557 cx.defer(move |cx| {
2558 handle
2559 .update(cx, |_view, window, _cx| {
2560 window.activate_window();
2561
2562 if let Some(workspace) = workspace_handle.upgrade() {
2563 workspace.update(_cx, |workspace, cx| {
2564 workspace.focus_panel::<AgentPanel>(window, cx);
2565 });
2566 }
2567 })
2568 .log_err();
2569 });
2570
2571 this.dismiss_notifications(cx);
2572 }
2573 AgentNotificationEvent::Dismissed => {
2574 this.dismiss_notifications(cx);
2575 }
2576 }
2577 }));
2578
2579 self.notifications.push(screen_window);
2580
2581 // If the user manually refocuses the original window, dismiss the popup.
2582 self.notification_subscriptions
2583 .entry(screen_window)
2584 .or_insert_with(Vec::new)
2585 .push({
2586 let pop_up_weak = pop_up.downgrade();
2587
2588 cx.observe_window_activation(window, move |_, window, cx| {
2589 if window.is_window_active() {
2590 if let Some(pop_up) = pop_up_weak.upgrade() {
2591 pop_up.update(cx, |_, cx| {
2592 cx.emit(AgentNotificationEvent::Dismissed);
2593 });
2594 }
2595 }
2596 })
2597 });
2598 }
2599 }
2600 }
2601
2602 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
2603 for window in self.notifications.drain(..) {
2604 window
2605 .update(cx, |_, window, _| {
2606 window.remove_window();
2607 })
2608 .ok();
2609
2610 self.notification_subscriptions.remove(&window);
2611 }
2612 }
2613
2614 fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement {
2615 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
2616 .icon_size(IconSize::XSmall)
2617 .icon_color(Color::Ignored)
2618 .tooltip(Tooltip::text("Open Thread as Markdown"))
2619 .on_click(cx.listener(move |this, _, window, cx| {
2620 if let Some(workspace) = this.workspace.upgrade() {
2621 this.open_thread_as_markdown(workspace, window, cx)
2622 .detach_and_log_err(cx);
2623 }
2624 }));
2625
2626 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
2627 .icon_size(IconSize::XSmall)
2628 .icon_color(Color::Ignored)
2629 .tooltip(Tooltip::text("Scroll To Top"))
2630 .on_click(cx.listener(move |this, _, _, cx| {
2631 this.scroll_to_top(cx);
2632 }));
2633
2634 h_flex()
2635 .w_full()
2636 .mr_1()
2637 .pb_2()
2638 .gap_1()
2639 .px(RESPONSE_PADDING_X)
2640 .opacity(0.4)
2641 .hover(|style| style.opacity(1.))
2642 .flex_wrap()
2643 .justify_end()
2644 .child(open_as_markdown)
2645 .child(scroll_to_top)
2646 }
2647
2648 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
2649 div()
2650 .id("acp-thread-scrollbar")
2651 .occlude()
2652 .on_mouse_move(cx.listener(|_, _, _, cx| {
2653 cx.notify();
2654 cx.stop_propagation()
2655 }))
2656 .on_hover(|_, _, cx| {
2657 cx.stop_propagation();
2658 })
2659 .on_any_mouse_down(|_, _, cx| {
2660 cx.stop_propagation();
2661 })
2662 .on_mouse_up(
2663 MouseButton::Left,
2664 cx.listener(|_, _, _, cx| {
2665 cx.stop_propagation();
2666 }),
2667 )
2668 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2669 cx.notify();
2670 }))
2671 .h_full()
2672 .absolute()
2673 .right_1()
2674 .top_1()
2675 .bottom_0()
2676 .w(px(12.))
2677 .cursor_default()
2678 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
2679 }
2680
2681 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2682 for diff_editor in self.diff_editors.values() {
2683 diff_editor.update(cx, |diff_editor, cx| {
2684 diff_editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
2685 cx.notify();
2686 })
2687 }
2688 }
2689
2690 pub(crate) fn insert_dragged_files(
2691 &self,
2692 paths: Vec<project::ProjectPath>,
2693 _added_worktrees: Vec<Entity<project::Worktree>>,
2694 window: &mut Window,
2695 cx: &mut Context<'_, Self>,
2696 ) {
2697 let buffer = self.message_editor.read(cx).buffer().clone();
2698 let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else {
2699 return;
2700 };
2701 let Some(buffer) = buffer.read(cx).as_singleton() else {
2702 return;
2703 };
2704 for path in paths {
2705 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
2706 continue;
2707 };
2708 let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
2709 continue;
2710 };
2711
2712 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
2713 let path_prefix = abs_path
2714 .file_name()
2715 .unwrap_or(path.path.as_os_str())
2716 .display()
2717 .to_string();
2718 let completion = ContextPickerCompletionProvider::completion_for_path(
2719 path,
2720 &path_prefix,
2721 false,
2722 entry.is_dir(),
2723 excerpt_id,
2724 anchor..anchor,
2725 self.message_editor.clone(),
2726 self.mention_set.clone(),
2727 cx,
2728 );
2729
2730 self.message_editor.update(cx, |message_editor, cx| {
2731 message_editor.edit(
2732 [(
2733 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
2734 completion.new_text,
2735 )],
2736 cx,
2737 );
2738 });
2739 if let Some(confirm) = completion.confirm.clone() {
2740 confirm(CompletionIntent::Complete, window, cx);
2741 }
2742 }
2743 }
2744}
2745
2746impl Focusable for AcpThreadView {
2747 fn focus_handle(&self, cx: &App) -> FocusHandle {
2748 self.message_editor.focus_handle(cx)
2749 }
2750}
2751
2752impl Render for AcpThreadView {
2753 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2754 v_flex()
2755 .size_full()
2756 .key_context("AcpThread")
2757 .on_action(cx.listener(Self::chat))
2758 .on_action(cx.listener(Self::previous_history_message))
2759 .on_action(cx.listener(Self::next_history_message))
2760 .on_action(cx.listener(Self::open_agent_diff))
2761 .bg(cx.theme().colors().panel_background)
2762 .child(match &self.thread_state {
2763 ThreadState::Unauthenticated { connection } => v_flex()
2764 .p_2()
2765 .flex_1()
2766 .items_center()
2767 .justify_center()
2768 .child(self.render_pending_auth_state())
2769 .child(h_flex().mt_1p5().justify_center().children(
2770 connection.auth_methods().into_iter().map(|method| {
2771 Button::new(
2772 SharedString::from(method.id.0.clone()),
2773 method.name.clone(),
2774 )
2775 .on_click({
2776 let method_id = method.id.clone();
2777 cx.listener(move |this, _, window, cx| {
2778 this.authenticate(method_id.clone(), window, cx)
2779 })
2780 })
2781 }),
2782 )),
2783 ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
2784 ThreadState::LoadError(e) => v_flex()
2785 .p_2()
2786 .flex_1()
2787 .items_center()
2788 .justify_center()
2789 .child(self.render_load_error(e, cx)),
2790 ThreadState::ServerExited { status } => v_flex()
2791 .p_2()
2792 .flex_1()
2793 .items_center()
2794 .justify_center()
2795 .child(self.render_server_exited(*status, cx)),
2796 ThreadState::Ready { thread, .. } => {
2797 let thread_clone = thread.clone();
2798
2799 v_flex().flex_1().map(|this| {
2800 if self.list_state.item_count() > 0 {
2801 this.child(
2802 list(
2803 self.list_state.clone(),
2804 cx.processor(|this, index: usize, window, cx| {
2805 let Some((entry, len)) = this.thread().and_then(|thread| {
2806 let entries = &thread.read(cx).entries();
2807 Some((entries.get(index)?, entries.len()))
2808 }) else {
2809 return Empty.into_any();
2810 };
2811 this.render_entry(index, len, entry, window, cx)
2812 }),
2813 )
2814 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
2815 .flex_grow()
2816 .into_any(),
2817 )
2818 .child(self.render_vertical_scrollbar(cx))
2819 .children(match thread_clone.read(cx).status() {
2820 ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => {
2821 None
2822 }
2823 ThreadStatus::Generating => div()
2824 .px_5()
2825 .py_2()
2826 .child(LoadingLabel::new("").size(LabelSize::Small))
2827 .into(),
2828 })
2829 .children(self.render_activity_bar(&thread_clone, window, cx))
2830 } else {
2831 this.child(self.render_empty_state(cx))
2832 }
2833 })
2834 }
2835 })
2836 .when_some(self.last_error.clone(), |el, error| {
2837 el.child(
2838 div()
2839 .p_2()
2840 .text_xs()
2841 .border_t_1()
2842 .border_color(cx.theme().colors().border)
2843 .bg(cx.theme().status().error_background)
2844 .child(
2845 self.render_markdown(error, default_markdown_style(false, window, cx)),
2846 ),
2847 )
2848 })
2849 .child(self.render_message_editor(window, cx))
2850 }
2851}
2852
2853fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
2854 let mut style = default_markdown_style(false, window, cx);
2855 let mut text_style = window.text_style();
2856 let theme_settings = ThemeSettings::get_global(cx);
2857
2858 let buffer_font = theme_settings.buffer_font.family.clone();
2859 let buffer_font_size = TextSize::Small.rems(cx);
2860
2861 text_style.refine(&TextStyleRefinement {
2862 font_family: Some(buffer_font),
2863 font_size: Some(buffer_font_size.into()),
2864 ..Default::default()
2865 });
2866
2867 style.base_text_style = text_style;
2868 style.link_callback = Some(Rc::new(move |url, cx| {
2869 if MentionPath::try_parse(url).is_some() {
2870 let colors = cx.theme().colors();
2871 Some(TextStyleRefinement {
2872 background_color: Some(colors.element_background),
2873 ..Default::default()
2874 })
2875 } else {
2876 None
2877 }
2878 }));
2879 style
2880}
2881
2882fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
2883 let theme_settings = ThemeSettings::get_global(cx);
2884 let colors = cx.theme().colors();
2885
2886 let buffer_font_size = TextSize::Small.rems(cx);
2887
2888 let mut text_style = window.text_style();
2889 let line_height = buffer_font_size * 1.75;
2890
2891 let font_family = if buffer_font {
2892 theme_settings.buffer_font.family.clone()
2893 } else {
2894 theme_settings.ui_font.family.clone()
2895 };
2896
2897 let font_size = if buffer_font {
2898 TextSize::Small.rems(cx)
2899 } else {
2900 TextSize::Default.rems(cx)
2901 };
2902
2903 text_style.refine(&TextStyleRefinement {
2904 font_family: Some(font_family),
2905 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
2906 font_features: Some(theme_settings.ui_font.features.clone()),
2907 font_size: Some(font_size.into()),
2908 line_height: Some(line_height.into()),
2909 color: Some(cx.theme().colors().text),
2910 ..Default::default()
2911 });
2912
2913 MarkdownStyle {
2914 base_text_style: text_style.clone(),
2915 syntax: cx.theme().syntax().clone(),
2916 selection_background_color: cx.theme().colors().element_selection_background,
2917 code_block_overflow_x_scroll: true,
2918 table_overflow_x_scroll: true,
2919 heading_level_styles: Some(HeadingLevelStyles {
2920 h1: Some(TextStyleRefinement {
2921 font_size: Some(rems(1.15).into()),
2922 ..Default::default()
2923 }),
2924 h2: Some(TextStyleRefinement {
2925 font_size: Some(rems(1.1).into()),
2926 ..Default::default()
2927 }),
2928 h3: Some(TextStyleRefinement {
2929 font_size: Some(rems(1.05).into()),
2930 ..Default::default()
2931 }),
2932 h4: Some(TextStyleRefinement {
2933 font_size: Some(rems(1.).into()),
2934 ..Default::default()
2935 }),
2936 h5: Some(TextStyleRefinement {
2937 font_size: Some(rems(0.95).into()),
2938 ..Default::default()
2939 }),
2940 h6: Some(TextStyleRefinement {
2941 font_size: Some(rems(0.875).into()),
2942 ..Default::default()
2943 }),
2944 }),
2945 code_block: StyleRefinement {
2946 padding: EdgesRefinement {
2947 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2948 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2949 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2950 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
2951 },
2952 margin: EdgesRefinement {
2953 top: Some(Length::Definite(Pixels(8.).into())),
2954 left: Some(Length::Definite(Pixels(0.).into())),
2955 right: Some(Length::Definite(Pixels(0.).into())),
2956 bottom: Some(Length::Definite(Pixels(12.).into())),
2957 },
2958 border_style: Some(BorderStyle::Solid),
2959 border_widths: EdgesRefinement {
2960 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
2961 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
2962 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
2963 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
2964 },
2965 border_color: Some(colors.border_variant),
2966 background: Some(colors.editor_background.into()),
2967 text: Some(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 ..Default::default()
2973 }),
2974 ..Default::default()
2975 },
2976 inline_code: TextStyleRefinement {
2977 font_family: Some(theme_settings.buffer_font.family.clone()),
2978 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
2979 font_features: Some(theme_settings.buffer_font.features.clone()),
2980 font_size: Some(buffer_font_size.into()),
2981 background_color: Some(colors.editor_foreground.opacity(0.08)),
2982 ..Default::default()
2983 },
2984 link: TextStyleRefinement {
2985 background_color: Some(colors.editor_foreground.opacity(0.025)),
2986 underline: Some(UnderlineStyle {
2987 color: Some(colors.text_accent.opacity(0.5)),
2988 thickness: px(1.),
2989 ..Default::default()
2990 }),
2991 ..Default::default()
2992 },
2993 ..Default::default()
2994 }
2995}
2996
2997fn plan_label_markdown_style(
2998 status: &acp::PlanEntryStatus,
2999 window: &Window,
3000 cx: &App,
3001) -> MarkdownStyle {
3002 let default_md_style = default_markdown_style(false, window, cx);
3003
3004 MarkdownStyle {
3005 base_text_style: TextStyle {
3006 color: cx.theme().colors().text_muted,
3007 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
3008 Some(gpui::StrikethroughStyle {
3009 thickness: px(1.),
3010 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
3011 })
3012 } else {
3013 None
3014 },
3015 ..default_md_style.base_text_style
3016 },
3017 ..default_md_style
3018 }
3019}
3020
3021fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
3022 TextStyleRefinement {
3023 font_size: Some(
3024 TextSize::Small
3025 .rems(cx)
3026 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
3027 .into(),
3028 ),
3029 ..Default::default()
3030 }
3031}
3032
3033#[cfg(test)]
3034mod tests {
3035 use agent_client_protocol::SessionId;
3036 use editor::EditorSettings;
3037 use fs::FakeFs;
3038 use futures::future::try_join_all;
3039 use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
3040 use lsp::{CompletionContext, CompletionTriggerKind};
3041 use project::CompletionIntent;
3042 use rand::Rng;
3043 use serde_json::json;
3044 use settings::SettingsStore;
3045 use util::path;
3046
3047 use super::*;
3048
3049 #[gpui::test]
3050 async fn test_drop(cx: &mut TestAppContext) {
3051 init_test(cx);
3052
3053 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default(), cx).await;
3054 let weak_view = thread_view.downgrade();
3055 drop(thread_view);
3056 assert!(!weak_view.is_upgradable());
3057 }
3058
3059 #[gpui::test]
3060 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3061 init_test(cx);
3062
3063 let (thread_view, cx) = setup_thread_view(StubAgentServer::default(), cx).await;
3064
3065 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3066 message_editor.update_in(cx, |editor, window, cx| {
3067 editor.set_text("Hello", window, cx);
3068 });
3069
3070 cx.deactivate_window();
3071
3072 thread_view.update_in(cx, |thread_view, window, cx| {
3073 thread_view.chat(&Chat, window, cx);
3074 });
3075
3076 cx.run_until_parked();
3077
3078 assert!(
3079 cx.windows()
3080 .iter()
3081 .any(|window| window.downcast::<AgentNotification>().is_some())
3082 );
3083 }
3084
3085 #[gpui::test]
3086 async fn test_notification_for_error(cx: &mut TestAppContext) {
3087 init_test(cx);
3088
3089 let (thread_view, cx) =
3090 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
3091
3092 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3093 message_editor.update_in(cx, |editor, window, cx| {
3094 editor.set_text("Hello", window, cx);
3095 });
3096
3097 cx.deactivate_window();
3098
3099 thread_view.update_in(cx, |thread_view, window, cx| {
3100 thread_view.chat(&Chat, window, cx);
3101 });
3102
3103 cx.run_until_parked();
3104
3105 assert!(
3106 cx.windows()
3107 .iter()
3108 .any(|window| window.downcast::<AgentNotification>().is_some())
3109 );
3110 }
3111
3112 #[gpui::test]
3113 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3114 init_test(cx);
3115
3116 let tool_call_id = acp::ToolCallId("1".into());
3117 let tool_call = acp::ToolCall {
3118 id: tool_call_id.clone(),
3119 title: "Label".into(),
3120 kind: acp::ToolKind::Edit,
3121 status: acp::ToolCallStatus::Pending,
3122 content: vec!["hi".into()],
3123 locations: vec![],
3124 raw_input: None,
3125 raw_output: None,
3126 };
3127 let connection = StubAgentConnection::new(vec![acp::SessionUpdate::ToolCall(tool_call)])
3128 .with_permission_requests(HashMap::from_iter([(
3129 tool_call_id,
3130 vec![acp::PermissionOption {
3131 id: acp::PermissionOptionId("1".into()),
3132 name: "Allow".into(),
3133 kind: acp::PermissionOptionKind::AllowOnce,
3134 }],
3135 )]));
3136 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3137
3138 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3139 message_editor.update_in(cx, |editor, window, cx| {
3140 editor.set_text("Hello", window, cx);
3141 });
3142
3143 cx.deactivate_window();
3144
3145 thread_view.update_in(cx, |thread_view, window, cx| {
3146 thread_view.chat(&Chat, window, cx);
3147 });
3148
3149 cx.run_until_parked();
3150
3151 assert!(
3152 cx.windows()
3153 .iter()
3154 .any(|window| window.downcast::<AgentNotification>().is_some())
3155 );
3156 }
3157
3158 #[gpui::test]
3159 async fn test_crease_removal(cx: &mut TestAppContext) {
3160 init_test(cx);
3161
3162 let fs = FakeFs::new(cx.executor());
3163 fs.insert_tree("/project", json!({"file": ""})).await;
3164 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3165 let agent = StubAgentServer::default();
3166 let (workspace, cx) =
3167 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3168 let thread_view = cx.update(|window, cx| {
3169 cx.new(|cx| {
3170 AcpThreadView::new(
3171 Rc::new(agent),
3172 workspace.downgrade(),
3173 project,
3174 Rc::new(RefCell::new(MessageHistory::default())),
3175 1,
3176 None,
3177 window,
3178 cx,
3179 )
3180 })
3181 });
3182
3183 cx.run_until_parked();
3184
3185 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3186 let excerpt_id = message_editor.update(cx, |editor, cx| {
3187 editor
3188 .buffer()
3189 .read(cx)
3190 .excerpt_ids()
3191 .into_iter()
3192 .next()
3193 .unwrap()
3194 });
3195 let completions = message_editor.update_in(cx, |editor, window, cx| {
3196 editor.set_text("Hello @", window, cx);
3197 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
3198 let completion_provider = editor.completion_provider().unwrap();
3199 completion_provider.completions(
3200 excerpt_id,
3201 &buffer,
3202 Anchor::MAX,
3203 CompletionContext {
3204 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
3205 trigger_character: Some("@".into()),
3206 },
3207 window,
3208 cx,
3209 )
3210 });
3211 let [_, completion]: [_; 2] = completions
3212 .await
3213 .unwrap()
3214 .into_iter()
3215 .flat_map(|response| response.completions)
3216 .collect::<Vec<_>>()
3217 .try_into()
3218 .unwrap();
3219
3220 message_editor.update_in(cx, |editor, window, cx| {
3221 let snapshot = editor.buffer().read(cx).snapshot(cx);
3222 let start = snapshot
3223 .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
3224 .unwrap();
3225 let end = snapshot
3226 .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
3227 .unwrap();
3228 editor.edit([(start..end, completion.new_text)], cx);
3229 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
3230 });
3231
3232 cx.run_until_parked();
3233
3234 // Backspace over the inserted crease (and the following space).
3235 message_editor.update_in(cx, |editor, window, cx| {
3236 editor.backspace(&Default::default(), window, cx);
3237 editor.backspace(&Default::default(), window, cx);
3238 });
3239
3240 thread_view.update_in(cx, |thread_view, window, cx| {
3241 thread_view.chat(&Chat, window, cx);
3242 });
3243
3244 cx.run_until_parked();
3245
3246 let content = thread_view.update_in(cx, |thread_view, _window, _cx| {
3247 thread_view
3248 .message_history
3249 .borrow()
3250 .items()
3251 .iter()
3252 .flatten()
3253 .cloned()
3254 .collect::<Vec<_>>()
3255 });
3256
3257 // We don't send a resource link for the deleted crease.
3258 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
3259 }
3260
3261 async fn setup_thread_view(
3262 agent: impl AgentServer + 'static,
3263 cx: &mut TestAppContext,
3264 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
3265 let fs = FakeFs::new(cx.executor());
3266 let project = Project::test(fs, [], cx).await;
3267 let (workspace, cx) =
3268 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3269
3270 let thread_view = cx.update(|window, cx| {
3271 cx.new(|cx| {
3272 AcpThreadView::new(
3273 Rc::new(agent),
3274 workspace.downgrade(),
3275 project,
3276 Rc::new(RefCell::new(MessageHistory::default())),
3277 1,
3278 None,
3279 window,
3280 cx,
3281 )
3282 })
3283 });
3284 cx.run_until_parked();
3285 (thread_view, cx)
3286 }
3287
3288 struct StubAgentServer<C> {
3289 connection: C,
3290 }
3291
3292 impl<C> StubAgentServer<C> {
3293 fn new(connection: C) -> Self {
3294 Self { connection }
3295 }
3296 }
3297
3298 impl StubAgentServer<StubAgentConnection> {
3299 fn default() -> Self {
3300 Self::new(StubAgentConnection::default())
3301 }
3302 }
3303
3304 impl<C> AgentServer for StubAgentServer<C>
3305 where
3306 C: 'static + AgentConnection + Send + Clone,
3307 {
3308 fn logo(&self) -> ui::IconName {
3309 unimplemented!()
3310 }
3311
3312 fn name(&self) -> &'static str {
3313 unimplemented!()
3314 }
3315
3316 fn empty_state_headline(&self) -> &'static str {
3317 unimplemented!()
3318 }
3319
3320 fn empty_state_message(&self) -> &'static str {
3321 unimplemented!()
3322 }
3323
3324 fn connect(
3325 &self,
3326 _root_dir: &Path,
3327 _project: &Entity<Project>,
3328 _cx: &mut App,
3329 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3330 Task::ready(Ok(Rc::new(self.connection.clone())))
3331 }
3332 }
3333
3334 #[derive(Clone, Default)]
3335 struct StubAgentConnection {
3336 sessions: Arc<Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
3337 permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
3338 updates: Vec<acp::SessionUpdate>,
3339 }
3340
3341 impl StubAgentConnection {
3342 fn new(updates: Vec<acp::SessionUpdate>) -> Self {
3343 Self {
3344 updates,
3345 permission_requests: HashMap::default(),
3346 sessions: Arc::default(),
3347 }
3348 }
3349
3350 fn with_permission_requests(
3351 mut self,
3352 permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
3353 ) -> Self {
3354 self.permission_requests = permission_requests;
3355 self
3356 }
3357 }
3358
3359 impl AgentConnection for StubAgentConnection {
3360 fn auth_methods(&self) -> &[acp::AuthMethod] {
3361 &[]
3362 }
3363
3364 fn new_thread(
3365 self: Rc<Self>,
3366 project: Entity<Project>,
3367 _cwd: &Path,
3368 cx: &mut gpui::AsyncApp,
3369 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3370 let session_id = SessionId(
3371 rand::thread_rng()
3372 .sample_iter(&rand::distributions::Alphanumeric)
3373 .take(7)
3374 .map(char::from)
3375 .collect::<String>()
3376 .into(),
3377 );
3378 let thread = cx
3379 .new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx))
3380 .unwrap();
3381 self.sessions.lock().insert(session_id, thread.downgrade());
3382 Task::ready(Ok(thread))
3383 }
3384
3385 fn authenticate(
3386 &self,
3387 _method_id: acp::AuthMethodId,
3388 _cx: &mut App,
3389 ) -> Task<gpui::Result<()>> {
3390 unimplemented!()
3391 }
3392
3393 fn prompt(
3394 &self,
3395 params: acp::PromptRequest,
3396 cx: &mut App,
3397 ) -> Task<gpui::Result<acp::PromptResponse>> {
3398 let sessions = self.sessions.lock();
3399 let thread = sessions.get(¶ms.session_id).unwrap();
3400 let mut tasks = vec![];
3401 for update in &self.updates {
3402 let thread = thread.clone();
3403 let update = update.clone();
3404 let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update
3405 && let Some(options) = self.permission_requests.get(&tool_call.id)
3406 {
3407 Some((tool_call.clone(), options.clone()))
3408 } else {
3409 None
3410 };
3411 let task = cx.spawn(async move |cx| {
3412 if let Some((tool_call, options)) = permission_request {
3413 let permission = thread.update(cx, |thread, cx| {
3414 thread.request_tool_call_authorization(
3415 tool_call.clone(),
3416 options.clone(),
3417 cx,
3418 )
3419 })?;
3420 permission.await?;
3421 }
3422 thread.update(cx, |thread, cx| {
3423 thread.handle_session_update(update.clone(), cx).unwrap();
3424 })?;
3425 anyhow::Ok(())
3426 });
3427 tasks.push(task);
3428 }
3429 cx.spawn(async move |_| {
3430 try_join_all(tasks).await?;
3431 Ok(acp::PromptResponse {
3432 stop_reason: acp::StopReason::EndTurn,
3433 })
3434 })
3435 }
3436
3437 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3438 unimplemented!()
3439 }
3440 }
3441
3442 #[derive(Clone)]
3443 struct SaboteurAgentConnection;
3444
3445 impl AgentConnection for SaboteurAgentConnection {
3446 fn new_thread(
3447 self: Rc<Self>,
3448 project: Entity<Project>,
3449 _cwd: &Path,
3450 cx: &mut gpui::AsyncApp,
3451 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3452 Task::ready(Ok(cx
3453 .new(|cx| {
3454 AcpThread::new(
3455 "SaboteurAgentConnection",
3456 self,
3457 project,
3458 SessionId("test".into()),
3459 cx,
3460 )
3461 })
3462 .unwrap()))
3463 }
3464
3465 fn auth_methods(&self) -> &[acp::AuthMethod] {
3466 &[]
3467 }
3468
3469 fn authenticate(
3470 &self,
3471 _method_id: acp::AuthMethodId,
3472 _cx: &mut App,
3473 ) -> Task<gpui::Result<()>> {
3474 unimplemented!()
3475 }
3476
3477 fn prompt(
3478 &self,
3479 _params: acp::PromptRequest,
3480 _cx: &mut App,
3481 ) -> Task<gpui::Result<acp::PromptResponse>> {
3482 Task::ready(Err(anyhow::anyhow!("Error prompting")))
3483 }
3484
3485 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3486 unimplemented!()
3487 }
3488 }
3489
3490 fn init_test(cx: &mut TestAppContext) {
3491 cx.update(|cx| {
3492 let settings_store = SettingsStore::test(cx);
3493 cx.set_global(settings_store);
3494 language::init(cx);
3495 Project::init_settings(cx);
3496 AgentSettings::register(cx);
3497 workspace::init_settings(cx);
3498 ThemeSettings::register(cx);
3499 release_channel::init(SemanticVersion::default(), cx);
3500 EditorSettings::register(cx);
3501 });
3502 }
3503}