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