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