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