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