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