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 .p_2()
1920 .gap_2()
1921 .flex_1()
1922 .items_center()
1923 .justify_center()
1924 .child(
1925 v_flex()
1926 .items_center()
1927 .justify_center()
1928 .child(self.render_error_agent_logo())
1929 .child(
1930 h_flex().mt_4().mb_1().justify_center().child(
1931 Headline::new("Authentication Required").size(HeadlineSize::Medium),
1932 ),
1933 )
1934 .into_any(),
1935 )
1936 .children(description.map(|desc| {
1937 div().text_ui(cx).text_center().child(
1938 self.render_markdown(desc.clone(), default_markdown_style(false, window, cx)),
1939 )
1940 }))
1941 .children(
1942 configuration_view
1943 .cloned()
1944 .map(|view| div().px_4().w_full().max_w_128().child(view)),
1945 )
1946 .child(h_flex().mt_1p5().justify_center().children(
1947 connection.auth_methods().into_iter().map(|method| {
1948 Button::new(SharedString::from(method.id.0.clone()), method.name.clone())
1949 .on_click({
1950 let method_id = method.id.clone();
1951 cx.listener(move |this, _, window, cx| {
1952 this.authenticate(method_id.clone(), window, cx)
1953 })
1954 })
1955 }),
1956 ))
1957 }
1958
1959 fn render_server_exited(&self, status: ExitStatus, _cx: &Context<Self>) -> AnyElement {
1960 v_flex()
1961 .items_center()
1962 .justify_center()
1963 .child(self.render_error_agent_logo())
1964 .child(
1965 v_flex()
1966 .mt_4()
1967 .mb_2()
1968 .gap_0p5()
1969 .text_center()
1970 .items_center()
1971 .child(Headline::new("Server exited unexpectedly").size(HeadlineSize::Medium))
1972 .child(
1973 Label::new(format!("Exit status: {}", status.code().unwrap_or(-127)))
1974 .size(LabelSize::Small)
1975 .color(Color::Muted),
1976 ),
1977 )
1978 .into_any_element()
1979 }
1980
1981 fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1982 let mut container = v_flex()
1983 .items_center()
1984 .justify_center()
1985 .child(self.render_error_agent_logo())
1986 .child(
1987 v_flex()
1988 .mt_4()
1989 .mb_2()
1990 .gap_0p5()
1991 .text_center()
1992 .items_center()
1993 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1994 .child(
1995 Label::new(e.to_string())
1996 .size(LabelSize::Small)
1997 .color(Color::Muted),
1998 ),
1999 );
2000
2001 if let LoadError::Unsupported {
2002 upgrade_message,
2003 upgrade_command,
2004 ..
2005 } = &e
2006 {
2007 let upgrade_message = upgrade_message.clone();
2008 let upgrade_command = upgrade_command.clone();
2009 container = container.child(Button::new("upgrade", upgrade_message).on_click(
2010 cx.listener(move |this, _, window, cx| {
2011 this.workspace
2012 .update(cx, |workspace, cx| {
2013 let project = workspace.project().read(cx);
2014 let cwd = project.first_project_directory(cx);
2015 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2016 let spawn_in_terminal = task::SpawnInTerminal {
2017 id: task::TaskId("install".to_string()),
2018 full_label: upgrade_command.clone(),
2019 label: upgrade_command.clone(),
2020 command: Some(upgrade_command.clone()),
2021 args: Vec::new(),
2022 command_label: upgrade_command.clone(),
2023 cwd,
2024 env: Default::default(),
2025 use_new_terminal: true,
2026 allow_concurrent_runs: true,
2027 reveal: Default::default(),
2028 reveal_target: Default::default(),
2029 hide: Default::default(),
2030 shell,
2031 show_summary: true,
2032 show_command: true,
2033 show_rerun: false,
2034 };
2035 workspace
2036 .spawn_in_terminal(spawn_in_terminal, window, cx)
2037 .detach();
2038 })
2039 .ok();
2040 }),
2041 ));
2042 }
2043
2044 container.into_any()
2045 }
2046
2047 fn render_activity_bar(
2048 &self,
2049 thread_entity: &Entity<AcpThread>,
2050 window: &mut Window,
2051 cx: &Context<Self>,
2052 ) -> Option<AnyElement> {
2053 let thread = thread_entity.read(cx);
2054 let action_log = thread.action_log();
2055 let changed_buffers = action_log.read(cx).changed_buffers(cx);
2056 let plan = thread.plan();
2057
2058 if changed_buffers.is_empty() && plan.is_empty() {
2059 return None;
2060 }
2061
2062 let editor_bg_color = cx.theme().colors().editor_background;
2063 let active_color = cx.theme().colors().element_selected;
2064 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
2065
2066 let pending_edits = thread.has_pending_edit_tool_calls();
2067
2068 v_flex()
2069 .mt_1()
2070 .mx_2()
2071 .bg(bg_edit_files_disclosure)
2072 .border_1()
2073 .border_b_0()
2074 .border_color(cx.theme().colors().border)
2075 .rounded_t_md()
2076 .shadow(vec![gpui::BoxShadow {
2077 color: gpui::black().opacity(0.15),
2078 offset: point(px(1.), px(-1.)),
2079 blur_radius: px(3.),
2080 spread_radius: px(0.),
2081 }])
2082 .when(!plan.is_empty(), |this| {
2083 this.child(self.render_plan_summary(plan, window, cx))
2084 .when(self.plan_expanded, |parent| {
2085 parent.child(self.render_plan_entries(plan, window, cx))
2086 })
2087 })
2088 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2089 this.child(Divider::horizontal().color(DividerColor::Border))
2090 })
2091 .when(!changed_buffers.is_empty(), |this| {
2092 this.child(self.render_edits_summary(
2093 action_log,
2094 &changed_buffers,
2095 self.edits_expanded,
2096 pending_edits,
2097 window,
2098 cx,
2099 ))
2100 .when(self.edits_expanded, |parent| {
2101 parent.child(self.render_edited_files(
2102 action_log,
2103 &changed_buffers,
2104 pending_edits,
2105 cx,
2106 ))
2107 })
2108 })
2109 .into_any()
2110 .into()
2111 }
2112
2113 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2114 let stats = plan.stats();
2115
2116 let title = if let Some(entry) = stats.in_progress_entry
2117 && !self.plan_expanded
2118 {
2119 h_flex()
2120 .w_full()
2121 .cursor_default()
2122 .gap_1()
2123 .text_xs()
2124 .text_color(cx.theme().colors().text_muted)
2125 .justify_between()
2126 .child(
2127 h_flex()
2128 .gap_1()
2129 .child(
2130 Label::new("Current:")
2131 .size(LabelSize::Small)
2132 .color(Color::Muted),
2133 )
2134 .child(MarkdownElement::new(
2135 entry.content.clone(),
2136 plan_label_markdown_style(&entry.status, window, cx),
2137 )),
2138 )
2139 .when(stats.pending > 0, |this| {
2140 this.child(
2141 Label::new(format!("{} left", stats.pending))
2142 .size(LabelSize::Small)
2143 .color(Color::Muted)
2144 .mr_1(),
2145 )
2146 })
2147 } else {
2148 let status_label = if stats.pending == 0 {
2149 "All Done".to_string()
2150 } else if stats.completed == 0 {
2151 format!("{} Tasks", plan.entries.len())
2152 } else {
2153 format!("{}/{}", stats.completed, plan.entries.len())
2154 };
2155
2156 h_flex()
2157 .w_full()
2158 .gap_1()
2159 .justify_between()
2160 .child(
2161 Label::new("Plan")
2162 .size(LabelSize::Small)
2163 .color(Color::Muted),
2164 )
2165 .child(
2166 Label::new(status_label)
2167 .size(LabelSize::Small)
2168 .color(Color::Muted)
2169 .mr_1(),
2170 )
2171 };
2172
2173 h_flex()
2174 .p_1()
2175 .justify_between()
2176 .when(self.plan_expanded, |this| {
2177 this.border_b_1().border_color(cx.theme().colors().border)
2178 })
2179 .child(
2180 h_flex()
2181 .id("plan_summary")
2182 .w_full()
2183 .gap_1()
2184 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
2185 .child(title)
2186 .on_click(cx.listener(|this, _, _, cx| {
2187 this.plan_expanded = !this.plan_expanded;
2188 cx.notify();
2189 })),
2190 )
2191 }
2192
2193 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2194 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
2195 let element = h_flex()
2196 .py_1()
2197 .px_2()
2198 .gap_2()
2199 .justify_between()
2200 .bg(cx.theme().colors().editor_background)
2201 .when(index < plan.entries.len() - 1, |parent| {
2202 parent.border_color(cx.theme().colors().border).border_b_1()
2203 })
2204 .child(
2205 h_flex()
2206 .id(("plan_entry", index))
2207 .gap_1p5()
2208 .max_w_full()
2209 .overflow_x_scroll()
2210 .text_xs()
2211 .text_color(cx.theme().colors().text_muted)
2212 .child(match entry.status {
2213 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
2214 .size(IconSize::Small)
2215 .color(Color::Muted)
2216 .into_any_element(),
2217 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
2218 .size(IconSize::Small)
2219 .color(Color::Accent)
2220 .with_animation(
2221 "running",
2222 Animation::new(Duration::from_secs(2)).repeat(),
2223 |icon, delta| {
2224 icon.transform(Transformation::rotate(percentage(delta)))
2225 },
2226 )
2227 .into_any_element(),
2228 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
2229 .size(IconSize::Small)
2230 .color(Color::Success)
2231 .into_any_element(),
2232 })
2233 .child(MarkdownElement::new(
2234 entry.content.clone(),
2235 plan_label_markdown_style(&entry.status, window, cx),
2236 )),
2237 );
2238
2239 Some(element)
2240 }))
2241 }
2242
2243 fn render_edits_summary(
2244 &self,
2245 action_log: &Entity<ActionLog>,
2246 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2247 expanded: bool,
2248 pending_edits: bool,
2249 window: &mut Window,
2250 cx: &Context<Self>,
2251 ) -> Div {
2252 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
2253
2254 let focus_handle = self.focus_handle(cx);
2255
2256 h_flex()
2257 .p_1()
2258 .justify_between()
2259 .when(expanded, |this| {
2260 this.border_b_1().border_color(cx.theme().colors().border)
2261 })
2262 .child(
2263 h_flex()
2264 .id("edits-container")
2265 .w_full()
2266 .gap_1()
2267 .child(Disclosure::new("edits-disclosure", expanded))
2268 .map(|this| {
2269 if pending_edits {
2270 this.child(
2271 Label::new(format!(
2272 "Editing {} {}…",
2273 changed_buffers.len(),
2274 if changed_buffers.len() == 1 {
2275 "file"
2276 } else {
2277 "files"
2278 }
2279 ))
2280 .color(Color::Muted)
2281 .size(LabelSize::Small)
2282 .with_animation(
2283 "edit-label",
2284 Animation::new(Duration::from_secs(2))
2285 .repeat()
2286 .with_easing(pulsating_between(0.3, 0.7)),
2287 |label, delta| label.alpha(delta),
2288 ),
2289 )
2290 } else {
2291 this.child(
2292 Label::new("Edits")
2293 .size(LabelSize::Small)
2294 .color(Color::Muted),
2295 )
2296 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
2297 .child(
2298 Label::new(format!(
2299 "{} {}",
2300 changed_buffers.len(),
2301 if changed_buffers.len() == 1 {
2302 "file"
2303 } else {
2304 "files"
2305 }
2306 ))
2307 .size(LabelSize::Small)
2308 .color(Color::Muted),
2309 )
2310 }
2311 })
2312 .on_click(cx.listener(|this, _, _, cx| {
2313 this.edits_expanded = !this.edits_expanded;
2314 cx.notify();
2315 })),
2316 )
2317 .child(
2318 h_flex()
2319 .gap_1()
2320 .child(
2321 IconButton::new("review-changes", IconName::ListTodo)
2322 .icon_size(IconSize::Small)
2323 .tooltip({
2324 let focus_handle = focus_handle.clone();
2325 move |window, cx| {
2326 Tooltip::for_action_in(
2327 "Review Changes",
2328 &OpenAgentDiff,
2329 &focus_handle,
2330 window,
2331 cx,
2332 )
2333 }
2334 })
2335 .on_click(cx.listener(|_, _, window, cx| {
2336 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
2337 })),
2338 )
2339 .child(Divider::vertical().color(DividerColor::Border))
2340 .child(
2341 Button::new("reject-all-changes", "Reject All")
2342 .label_size(LabelSize::Small)
2343 .disabled(pending_edits)
2344 .when(pending_edits, |this| {
2345 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2346 })
2347 .key_binding(
2348 KeyBinding::for_action_in(
2349 &RejectAll,
2350 &focus_handle.clone(),
2351 window,
2352 cx,
2353 )
2354 .map(|kb| kb.size(rems_from_px(10.))),
2355 )
2356 .on_click({
2357 let action_log = action_log.clone();
2358 cx.listener(move |_, _, _, cx| {
2359 action_log.update(cx, |action_log, cx| {
2360 action_log.reject_all_edits(cx).detach();
2361 })
2362 })
2363 }),
2364 )
2365 .child(
2366 Button::new("keep-all-changes", "Keep All")
2367 .label_size(LabelSize::Small)
2368 .disabled(pending_edits)
2369 .when(pending_edits, |this| {
2370 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2371 })
2372 .key_binding(
2373 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
2374 .map(|kb| kb.size(rems_from_px(10.))),
2375 )
2376 .on_click({
2377 let action_log = action_log.clone();
2378 cx.listener(move |_, _, _, cx| {
2379 action_log.update(cx, |action_log, cx| {
2380 action_log.keep_all_edits(cx);
2381 })
2382 })
2383 }),
2384 ),
2385 )
2386 }
2387
2388 fn render_edited_files(
2389 &self,
2390 action_log: &Entity<ActionLog>,
2391 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2392 pending_edits: bool,
2393 cx: &Context<Self>,
2394 ) -> Div {
2395 let editor_bg_color = cx.theme().colors().editor_background;
2396
2397 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
2398 |(index, (buffer, _diff))| {
2399 let file = buffer.read(cx).file()?;
2400 let path = file.path();
2401
2402 let file_path = path.parent().and_then(|parent| {
2403 let parent_str = parent.to_string_lossy();
2404
2405 if parent_str.is_empty() {
2406 None
2407 } else {
2408 Some(
2409 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
2410 .color(Color::Muted)
2411 .size(LabelSize::XSmall)
2412 .buffer_font(cx),
2413 )
2414 }
2415 });
2416
2417 let file_name = path.file_name().map(|name| {
2418 Label::new(name.to_string_lossy().to_string())
2419 .size(LabelSize::XSmall)
2420 .buffer_font(cx)
2421 });
2422
2423 let file_icon = FileIcons::get_icon(path, cx)
2424 .map(Icon::from_path)
2425 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
2426 .unwrap_or_else(|| {
2427 Icon::new(IconName::File)
2428 .color(Color::Muted)
2429 .size(IconSize::Small)
2430 });
2431
2432 let overlay_gradient = linear_gradient(
2433 90.,
2434 linear_color_stop(editor_bg_color, 1.),
2435 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
2436 );
2437
2438 let element = h_flex()
2439 .group("edited-code")
2440 .id(("file-container", index))
2441 .relative()
2442 .py_1()
2443 .pl_2()
2444 .pr_1()
2445 .gap_2()
2446 .justify_between()
2447 .bg(editor_bg_color)
2448 .when(index < changed_buffers.len() - 1, |parent| {
2449 parent.border_color(cx.theme().colors().border).border_b_1()
2450 })
2451 .child(
2452 h_flex()
2453 .id(("file-name", index))
2454 .pr_8()
2455 .gap_1p5()
2456 .max_w_full()
2457 .overflow_x_scroll()
2458 .child(file_icon)
2459 .child(h_flex().gap_0p5().children(file_name).children(file_path))
2460 .on_click({
2461 let buffer = buffer.clone();
2462 cx.listener(move |this, _, window, cx| {
2463 this.open_edited_buffer(&buffer, window, cx);
2464 })
2465 }),
2466 )
2467 .child(
2468 h_flex()
2469 .gap_1()
2470 .visible_on_hover("edited-code")
2471 .child(
2472 Button::new("review", "Review")
2473 .label_size(LabelSize::Small)
2474 .on_click({
2475 let buffer = buffer.clone();
2476 cx.listener(move |this, _, window, cx| {
2477 this.open_edited_buffer(&buffer, window, cx);
2478 })
2479 }),
2480 )
2481 .child(Divider::vertical().color(DividerColor::BorderVariant))
2482 .child(
2483 Button::new("reject-file", "Reject")
2484 .label_size(LabelSize::Small)
2485 .disabled(pending_edits)
2486 .on_click({
2487 let buffer = buffer.clone();
2488 let action_log = action_log.clone();
2489 move |_, _, cx| {
2490 action_log.update(cx, |action_log, cx| {
2491 action_log
2492 .reject_edits_in_ranges(
2493 buffer.clone(),
2494 vec![Anchor::MIN..Anchor::MAX],
2495 cx,
2496 )
2497 .detach_and_log_err(cx);
2498 })
2499 }
2500 }),
2501 )
2502 .child(
2503 Button::new("keep-file", "Keep")
2504 .label_size(LabelSize::Small)
2505 .disabled(pending_edits)
2506 .on_click({
2507 let buffer = buffer.clone();
2508 let action_log = action_log.clone();
2509 move |_, _, cx| {
2510 action_log.update(cx, |action_log, cx| {
2511 action_log.keep_edits_in_range(
2512 buffer.clone(),
2513 Anchor::MIN..Anchor::MAX,
2514 cx,
2515 );
2516 })
2517 }
2518 }),
2519 ),
2520 )
2521 .child(
2522 div()
2523 .id("gradient-overlay")
2524 .absolute()
2525 .h_full()
2526 .w_12()
2527 .top_0()
2528 .bottom_0()
2529 .right(px(152.))
2530 .bg(overlay_gradient),
2531 );
2532
2533 Some(element)
2534 },
2535 ))
2536 }
2537
2538 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2539 let focus_handle = self.message_editor.focus_handle(cx);
2540 let editor_bg_color = cx.theme().colors().editor_background;
2541 let (expand_icon, expand_tooltip) = if self.editor_expanded {
2542 (IconName::Minimize, "Minimize Message Editor")
2543 } else {
2544 (IconName::Maximize, "Expand Message Editor")
2545 };
2546
2547 v_flex()
2548 .on_action(cx.listener(Self::expand_message_editor))
2549 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
2550 if let Some(profile_selector) = this.profile_selector.as_ref() {
2551 profile_selector.read(cx).menu_handle().toggle(window, cx);
2552 }
2553 }))
2554 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
2555 if let Some(model_selector) = this.model_selector.as_ref() {
2556 model_selector
2557 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
2558 }
2559 }))
2560 .p_2()
2561 .gap_2()
2562 .border_t_1()
2563 .border_color(cx.theme().colors().border)
2564 .bg(editor_bg_color)
2565 .when(self.editor_expanded, |this| {
2566 this.h(vh(0.8, window)).size_full().justify_between()
2567 })
2568 .child(
2569 v_flex()
2570 .relative()
2571 .size_full()
2572 .pt_1()
2573 .pr_2p5()
2574 .child(self.message_editor.clone())
2575 .child(
2576 h_flex()
2577 .absolute()
2578 .top_0()
2579 .right_0()
2580 .opacity(0.5)
2581 .hover(|this| this.opacity(1.0))
2582 .child(
2583 IconButton::new("toggle-height", expand_icon)
2584 .icon_size(IconSize::Small)
2585 .icon_color(Color::Muted)
2586 .tooltip({
2587 let focus_handle = focus_handle.clone();
2588 move |window, cx| {
2589 Tooltip::for_action_in(
2590 expand_tooltip,
2591 &ExpandMessageEditor,
2592 &focus_handle,
2593 window,
2594 cx,
2595 )
2596 }
2597 })
2598 .on_click(cx.listener(|_, _, window, cx| {
2599 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
2600 })),
2601 ),
2602 ),
2603 )
2604 .child(
2605 h_flex()
2606 .flex_none()
2607 .justify_between()
2608 .child(
2609 h_flex()
2610 .gap_1()
2611 .child(self.render_follow_toggle(cx))
2612 .children(self.render_burn_mode_toggle(cx)),
2613 )
2614 .child(
2615 h_flex()
2616 .gap_1()
2617 .children(self.profile_selector.clone())
2618 .children(self.model_selector.clone())
2619 .child(self.render_send_button(cx)),
2620 ),
2621 )
2622 .into_any()
2623 }
2624
2625 pub(crate) fn as_native_connection(
2626 &self,
2627 cx: &App,
2628 ) -> Option<Rc<agent2::NativeAgentConnection>> {
2629 let acp_thread = self.thread()?.read(cx);
2630 acp_thread.connection().clone().downcast()
2631 }
2632
2633 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
2634 let acp_thread = self.thread()?.read(cx);
2635 self.as_native_connection(cx)?
2636 .thread(acp_thread.session_id(), cx)
2637 }
2638
2639 fn toggle_burn_mode(
2640 &mut self,
2641 _: &ToggleBurnMode,
2642 _window: &mut Window,
2643 cx: &mut Context<Self>,
2644 ) {
2645 let Some(thread) = self.as_native_thread(cx) else {
2646 return;
2647 };
2648
2649 thread.update(cx, |thread, _cx| {
2650 let current_mode = thread.completion_mode();
2651 thread.set_completion_mode(match current_mode {
2652 CompletionMode::Burn => CompletionMode::Normal,
2653 CompletionMode::Normal => CompletionMode::Burn,
2654 });
2655 });
2656 }
2657
2658 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
2659 let thread = self.as_native_thread(cx)?.read(cx);
2660
2661 if thread
2662 .model()
2663 .map_or(true, |model| !model.supports_burn_mode())
2664 {
2665 return None;
2666 }
2667
2668 let active_completion_mode = thread.completion_mode();
2669 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
2670 let icon = if burn_mode_enabled {
2671 IconName::ZedBurnModeOn
2672 } else {
2673 IconName::ZedBurnMode
2674 };
2675
2676 Some(
2677 IconButton::new("burn-mode", icon)
2678 .icon_size(IconSize::Small)
2679 .icon_color(Color::Muted)
2680 .toggle_state(burn_mode_enabled)
2681 .selected_icon_color(Color::Error)
2682 .on_click(cx.listener(|this, _event, window, cx| {
2683 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
2684 }))
2685 .tooltip(move |_window, cx| {
2686 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
2687 .into()
2688 })
2689 .into_any_element(),
2690 )
2691 }
2692
2693 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
2694 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
2695 let is_generating = self.thread().map_or(false, |thread| {
2696 thread.read(cx).status() != ThreadStatus::Idle
2697 });
2698
2699 if is_generating && is_editor_empty {
2700 IconButton::new("stop-generation", IconName::Stop)
2701 .icon_color(Color::Error)
2702 .style(ButtonStyle::Tinted(ui::TintColor::Error))
2703 .tooltip(move |window, cx| {
2704 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
2705 })
2706 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
2707 .into_any_element()
2708 } else {
2709 let send_btn_tooltip = if is_editor_empty && !is_generating {
2710 "Type to Send"
2711 } else if is_generating {
2712 "Stop and Send Message"
2713 } else {
2714 "Send"
2715 };
2716
2717 IconButton::new("send-message", IconName::Send)
2718 .style(ButtonStyle::Filled)
2719 .map(|this| {
2720 if is_editor_empty && !is_generating {
2721 this.disabled(true).icon_color(Color::Muted)
2722 } else {
2723 this.icon_color(Color::Accent)
2724 }
2725 })
2726 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
2727 .on_click(cx.listener(|this, _, window, cx| {
2728 this.send(window, cx);
2729 }))
2730 .into_any_element()
2731 }
2732 }
2733
2734 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2735 let following = self
2736 .workspace
2737 .read_with(cx, |workspace, _| {
2738 workspace.is_being_followed(CollaboratorId::Agent)
2739 })
2740 .unwrap_or(false);
2741
2742 IconButton::new("follow-agent", IconName::Crosshair)
2743 .icon_size(IconSize::Small)
2744 .icon_color(Color::Muted)
2745 .toggle_state(following)
2746 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
2747 .tooltip(move |window, cx| {
2748 if following {
2749 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
2750 } else {
2751 Tooltip::with_meta(
2752 "Follow Agent",
2753 Some(&Follow),
2754 "Track the agent's location as it reads and edits files.",
2755 window,
2756 cx,
2757 )
2758 }
2759 })
2760 .on_click(cx.listener(move |this, _, window, cx| {
2761 this.workspace
2762 .update(cx, |workspace, cx| {
2763 if following {
2764 workspace.unfollow(CollaboratorId::Agent, window, cx);
2765 } else {
2766 workspace.follow(CollaboratorId::Agent, window, cx);
2767 }
2768 })
2769 .ok();
2770 }))
2771 }
2772
2773 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2774 let workspace = self.workspace.clone();
2775 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2776 Self::open_link(text, &workspace, window, cx);
2777 })
2778 }
2779
2780 fn open_link(
2781 url: SharedString,
2782 workspace: &WeakEntity<Workspace>,
2783 window: &mut Window,
2784 cx: &mut App,
2785 ) {
2786 let Some(workspace) = workspace.upgrade() else {
2787 cx.open_url(&url);
2788 return;
2789 };
2790
2791 if let Some(mention) = MentionUri::parse(&url).log_err() {
2792 workspace.update(cx, |workspace, cx| match mention {
2793 MentionUri::File { abs_path, .. } => {
2794 let project = workspace.project();
2795 let Some((path, entry)) = project.update(cx, |project, cx| {
2796 let path = project.find_project_path(abs_path, cx)?;
2797 let entry = project.entry_for_path(&path, cx)?;
2798 Some((path, entry))
2799 }) else {
2800 return;
2801 };
2802
2803 if entry.is_dir() {
2804 project.update(cx, |_, cx| {
2805 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2806 });
2807 } else {
2808 workspace
2809 .open_path(path, None, true, window, cx)
2810 .detach_and_log_err(cx);
2811 }
2812 }
2813 MentionUri::Symbol {
2814 path, line_range, ..
2815 }
2816 | MentionUri::Selection { path, line_range } => {
2817 let project = workspace.project();
2818 let Some((path, _)) = project.update(cx, |project, cx| {
2819 let path = project.find_project_path(path, cx)?;
2820 let entry = project.entry_for_path(&path, cx)?;
2821 Some((path, entry))
2822 }) else {
2823 return;
2824 };
2825
2826 let item = workspace.open_path(path, None, true, window, cx);
2827 window
2828 .spawn(cx, async move |cx| {
2829 let Some(editor) = item.await?.downcast::<Editor>() else {
2830 return Ok(());
2831 };
2832 let range =
2833 Point::new(line_range.start, 0)..Point::new(line_range.start, 0);
2834 editor
2835 .update_in(cx, |editor, window, cx| {
2836 editor.change_selections(
2837 SelectionEffects::scroll(Autoscroll::center()),
2838 window,
2839 cx,
2840 |s| s.select_ranges(vec![range]),
2841 );
2842 })
2843 .ok();
2844 anyhow::Ok(())
2845 })
2846 .detach_and_log_err(cx);
2847 }
2848 MentionUri::Thread { id, .. } => {
2849 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2850 panel.update(cx, |panel, cx| {
2851 panel
2852 .open_thread_by_id(&id, window, cx)
2853 .detach_and_log_err(cx)
2854 });
2855 }
2856 }
2857 MentionUri::TextThread { path, .. } => {
2858 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2859 panel.update(cx, |panel, cx| {
2860 panel
2861 .open_saved_prompt_editor(path.as_path().into(), window, cx)
2862 .detach_and_log_err(cx);
2863 });
2864 }
2865 }
2866 MentionUri::Rule { id, .. } => {
2867 let PromptId::User { uuid } = id else {
2868 return;
2869 };
2870 window.dispatch_action(
2871 Box::new(OpenRulesLibrary {
2872 prompt_to_select: Some(uuid.0),
2873 }),
2874 cx,
2875 )
2876 }
2877 MentionUri::Fetch { url } => {
2878 cx.open_url(url.as_str());
2879 }
2880 })
2881 } else {
2882 cx.open_url(&url);
2883 }
2884 }
2885
2886 fn open_tool_call_location(
2887 &self,
2888 entry_ix: usize,
2889 location_ix: usize,
2890 window: &mut Window,
2891 cx: &mut Context<Self>,
2892 ) -> Option<()> {
2893 let (tool_call_location, agent_location) = self
2894 .thread()?
2895 .read(cx)
2896 .entries()
2897 .get(entry_ix)?
2898 .location(location_ix)?;
2899
2900 let project_path = self
2901 .project
2902 .read(cx)
2903 .find_project_path(&tool_call_location.path, cx)?;
2904
2905 let open_task = self
2906 .workspace
2907 .update(cx, |workspace, cx| {
2908 workspace.open_path(project_path, None, true, window, cx)
2909 })
2910 .log_err()?;
2911 window
2912 .spawn(cx, async move |cx| {
2913 let item = open_task.await?;
2914
2915 let Some(active_editor) = item.downcast::<Editor>() else {
2916 return anyhow::Ok(());
2917 };
2918
2919 active_editor.update_in(cx, |editor, window, cx| {
2920 let multibuffer = editor.buffer().read(cx);
2921 let buffer = multibuffer.as_singleton();
2922 if agent_location.buffer.upgrade() == buffer {
2923 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
2924 let anchor = editor::Anchor::in_buffer(
2925 excerpt_id.unwrap(),
2926 buffer.unwrap().read(cx).remote_id(),
2927 agent_location.position,
2928 );
2929 editor.change_selections(Default::default(), window, cx, |selections| {
2930 selections.select_anchor_ranges([anchor..anchor]);
2931 })
2932 } else {
2933 let row = tool_call_location.line.unwrap_or_default();
2934 editor.change_selections(Default::default(), window, cx, |selections| {
2935 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
2936 })
2937 }
2938 })?;
2939
2940 anyhow::Ok(())
2941 })
2942 .detach_and_log_err(cx);
2943
2944 None
2945 }
2946
2947 pub fn open_thread_as_markdown(
2948 &self,
2949 workspace: Entity<Workspace>,
2950 window: &mut Window,
2951 cx: &mut App,
2952 ) -> Task<anyhow::Result<()>> {
2953 let markdown_language_task = workspace
2954 .read(cx)
2955 .app_state()
2956 .languages
2957 .language_for_name("Markdown");
2958
2959 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
2960 let thread = thread.read(cx);
2961 (thread.title().to_string(), thread.to_markdown(cx))
2962 } else {
2963 return Task::ready(Ok(()));
2964 };
2965
2966 window.spawn(cx, async move |cx| {
2967 let markdown_language = markdown_language_task.await?;
2968
2969 workspace.update_in(cx, |workspace, window, cx| {
2970 let project = workspace.project().clone();
2971
2972 if !project.read(cx).is_local() {
2973 bail!("failed to open active thread as markdown in remote project");
2974 }
2975
2976 let buffer = project.update(cx, |project, cx| {
2977 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2978 });
2979 let buffer = cx.new(|cx| {
2980 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2981 });
2982
2983 workspace.add_item_to_active_pane(
2984 Box::new(cx.new(|cx| {
2985 let mut editor =
2986 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2987 editor.set_breadcrumb_header(thread_summary);
2988 editor
2989 })),
2990 None,
2991 true,
2992 window,
2993 cx,
2994 );
2995
2996 anyhow::Ok(())
2997 })??;
2998 anyhow::Ok(())
2999 })
3000 }
3001
3002 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
3003 self.list_state.scroll_to(ListOffset::default());
3004 cx.notify();
3005 }
3006
3007 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
3008 if let Some(thread) = self.thread() {
3009 let entry_count = thread.read(cx).entries().len();
3010 self.list_state.reset(entry_count);
3011 cx.notify();
3012 }
3013 }
3014
3015 fn notify_with_sound(
3016 &mut self,
3017 caption: impl Into<SharedString>,
3018 icon: IconName,
3019 window: &mut Window,
3020 cx: &mut Context<Self>,
3021 ) {
3022 self.play_notification_sound(window, cx);
3023 self.show_notification(caption, icon, window, cx);
3024 }
3025
3026 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
3027 let settings = AgentSettings::get_global(cx);
3028 if settings.play_sound_when_agent_done && !window.is_window_active() {
3029 Audio::play_sound(Sound::AgentDone, cx);
3030 }
3031 }
3032
3033 fn show_notification(
3034 &mut self,
3035 caption: impl Into<SharedString>,
3036 icon: IconName,
3037 window: &mut Window,
3038 cx: &mut Context<Self>,
3039 ) {
3040 if window.is_window_active() || !self.notifications.is_empty() {
3041 return;
3042 }
3043
3044 let title = self.title(cx);
3045
3046 match AgentSettings::get_global(cx).notify_when_agent_waiting {
3047 NotifyWhenAgentWaiting::PrimaryScreen => {
3048 if let Some(primary) = cx.primary_display() {
3049 self.pop_up(icon, caption.into(), title, window, primary, cx);
3050 }
3051 }
3052 NotifyWhenAgentWaiting::AllScreens => {
3053 let caption = caption.into();
3054 for screen in cx.displays() {
3055 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
3056 }
3057 }
3058 NotifyWhenAgentWaiting::Never => {
3059 // Don't show anything
3060 }
3061 }
3062 }
3063
3064 fn pop_up(
3065 &mut self,
3066 icon: IconName,
3067 caption: SharedString,
3068 title: SharedString,
3069 window: &mut Window,
3070 screen: Rc<dyn PlatformDisplay>,
3071 cx: &mut Context<Self>,
3072 ) {
3073 let options = AgentNotification::window_options(screen, cx);
3074
3075 let project_name = self.workspace.upgrade().and_then(|workspace| {
3076 workspace
3077 .read(cx)
3078 .project()
3079 .read(cx)
3080 .visible_worktrees(cx)
3081 .next()
3082 .map(|worktree| worktree.read(cx).root_name().to_string())
3083 });
3084
3085 if let Some(screen_window) = cx
3086 .open_window(options, |_, cx| {
3087 cx.new(|_| {
3088 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
3089 })
3090 })
3091 .log_err()
3092 {
3093 if let Some(pop_up) = screen_window.entity(cx).log_err() {
3094 self.notification_subscriptions
3095 .entry(screen_window)
3096 .or_insert_with(Vec::new)
3097 .push(cx.subscribe_in(&pop_up, window, {
3098 |this, _, event, window, cx| match event {
3099 AgentNotificationEvent::Accepted => {
3100 let handle = window.window_handle();
3101 cx.activate(true);
3102
3103 let workspace_handle = this.workspace.clone();
3104
3105 // If there are multiple Zed windows, activate the correct one.
3106 cx.defer(move |cx| {
3107 handle
3108 .update(cx, |_view, window, _cx| {
3109 window.activate_window();
3110
3111 if let Some(workspace) = workspace_handle.upgrade() {
3112 workspace.update(_cx, |workspace, cx| {
3113 workspace.focus_panel::<AgentPanel>(window, cx);
3114 });
3115 }
3116 })
3117 .log_err();
3118 });
3119
3120 this.dismiss_notifications(cx);
3121 }
3122 AgentNotificationEvent::Dismissed => {
3123 this.dismiss_notifications(cx);
3124 }
3125 }
3126 }));
3127
3128 self.notifications.push(screen_window);
3129
3130 // If the user manually refocuses the original window, dismiss the popup.
3131 self.notification_subscriptions
3132 .entry(screen_window)
3133 .or_insert_with(Vec::new)
3134 .push({
3135 let pop_up_weak = pop_up.downgrade();
3136
3137 cx.observe_window_activation(window, move |_, window, cx| {
3138 if window.is_window_active() {
3139 if let Some(pop_up) = pop_up_weak.upgrade() {
3140 pop_up.update(cx, |_, cx| {
3141 cx.emit(AgentNotificationEvent::Dismissed);
3142 });
3143 }
3144 }
3145 })
3146 });
3147 }
3148 }
3149 }
3150
3151 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
3152 for window in self.notifications.drain(..) {
3153 window
3154 .update(cx, |_, window, _| {
3155 window.remove_window();
3156 })
3157 .ok();
3158
3159 self.notification_subscriptions.remove(&window);
3160 }
3161 }
3162
3163 fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement {
3164 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
3165 .shape(ui::IconButtonShape::Square)
3166 .icon_size(IconSize::Small)
3167 .icon_color(Color::Ignored)
3168 .tooltip(Tooltip::text("Open Thread as Markdown"))
3169 .on_click(cx.listener(move |this, _, window, cx| {
3170 if let Some(workspace) = this.workspace.upgrade() {
3171 this.open_thread_as_markdown(workspace, window, cx)
3172 .detach_and_log_err(cx);
3173 }
3174 }));
3175
3176 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
3177 .shape(ui::IconButtonShape::Square)
3178 .icon_size(IconSize::Small)
3179 .icon_color(Color::Ignored)
3180 .tooltip(Tooltip::text("Scroll To Top"))
3181 .on_click(cx.listener(move |this, _, _, cx| {
3182 this.scroll_to_top(cx);
3183 }));
3184
3185 h_flex()
3186 .w_full()
3187 .mr_1()
3188 .pb_2()
3189 .px(RESPONSE_PADDING_X)
3190 .opacity(0.4)
3191 .hover(|style| style.opacity(1.))
3192 .flex_wrap()
3193 .justify_end()
3194 .child(open_as_markdown)
3195 .child(scroll_to_top)
3196 }
3197
3198 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
3199 div()
3200 .id("acp-thread-scrollbar")
3201 .occlude()
3202 .on_mouse_move(cx.listener(|_, _, _, cx| {
3203 cx.notify();
3204 cx.stop_propagation()
3205 }))
3206 .on_hover(|_, _, cx| {
3207 cx.stop_propagation();
3208 })
3209 .on_any_mouse_down(|_, _, cx| {
3210 cx.stop_propagation();
3211 })
3212 .on_mouse_up(
3213 MouseButton::Left,
3214 cx.listener(|_, _, _, cx| {
3215 cx.stop_propagation();
3216 }),
3217 )
3218 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3219 cx.notify();
3220 }))
3221 .h_full()
3222 .absolute()
3223 .right_1()
3224 .top_1()
3225 .bottom_0()
3226 .w(px(12.))
3227 .cursor_default()
3228 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
3229 }
3230
3231 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3232 self.entry_view_state.update(cx, |entry_view_state, cx| {
3233 entry_view_state.settings_changed(cx);
3234 });
3235 }
3236
3237 pub(crate) fn insert_dragged_files(
3238 &self,
3239 paths: Vec<project::ProjectPath>,
3240 added_worktrees: Vec<Entity<project::Worktree>>,
3241 window: &mut Window,
3242 cx: &mut Context<Self>,
3243 ) {
3244 self.message_editor.update(cx, |message_editor, cx| {
3245 message_editor.insert_dragged_files(paths, window, cx);
3246 drop(added_worktrees);
3247 })
3248 }
3249
3250 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<'_, Self>) -> Option<Div> {
3251 let content = match self.thread_error.as_ref()? {
3252 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
3253 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
3254 ThreadError::ModelRequestLimitReached(plan) => {
3255 self.render_model_request_limit_reached_error(*plan, cx)
3256 }
3257 ThreadError::ToolUseLimitReached => {
3258 self.render_tool_use_limit_reached_error(window, cx)?
3259 }
3260 };
3261
3262 Some(div().child(content))
3263 }
3264
3265 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
3266 Callout::new()
3267 .severity(Severity::Error)
3268 .title("Error")
3269 .description(error.clone())
3270 .actions_slot(self.create_copy_button(error.to_string()))
3271 .dismiss_action(self.dismiss_error_button(cx))
3272 }
3273
3274 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
3275 const ERROR_MESSAGE: &str =
3276 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
3277
3278 Callout::new()
3279 .severity(Severity::Error)
3280 .title("Free Usage Exceeded")
3281 .description(ERROR_MESSAGE)
3282 .actions_slot(
3283 h_flex()
3284 .gap_0p5()
3285 .child(self.upgrade_button(cx))
3286 .child(self.create_copy_button(ERROR_MESSAGE)),
3287 )
3288 .dismiss_action(self.dismiss_error_button(cx))
3289 }
3290
3291 fn render_model_request_limit_reached_error(
3292 &self,
3293 plan: cloud_llm_client::Plan,
3294 cx: &mut Context<Self>,
3295 ) -> Callout {
3296 let error_message = match plan {
3297 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
3298 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
3299 "Upgrade to Zed Pro for more prompts."
3300 }
3301 };
3302
3303 Callout::new()
3304 .severity(Severity::Error)
3305 .title("Model Prompt Limit Reached")
3306 .description(error_message)
3307 .actions_slot(
3308 h_flex()
3309 .gap_0p5()
3310 .child(self.upgrade_button(cx))
3311 .child(self.create_copy_button(error_message)),
3312 )
3313 .dismiss_action(self.dismiss_error_button(cx))
3314 }
3315
3316 fn render_tool_use_limit_reached_error(
3317 &self,
3318 window: &mut Window,
3319 cx: &mut Context<Self>,
3320 ) -> Option<Callout> {
3321 let thread = self.as_native_thread(cx)?;
3322 let supports_burn_mode = thread
3323 .read(cx)
3324 .model()
3325 .map_or(false, |model| model.supports_burn_mode());
3326
3327 let focus_handle = self.focus_handle(cx);
3328
3329 Some(
3330 Callout::new()
3331 .icon(IconName::Info)
3332 .title("Consecutive tool use limit reached.")
3333 .actions_slot(
3334 h_flex()
3335 .gap_0p5()
3336 .when(supports_burn_mode, |this| {
3337 this.child(
3338 Button::new("continue-burn-mode", "Continue with Burn Mode")
3339 .style(ButtonStyle::Filled)
3340 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3341 .layer(ElevationIndex::ModalSurface)
3342 .label_size(LabelSize::Small)
3343 .key_binding(
3344 KeyBinding::for_action_in(
3345 &ContinueWithBurnMode,
3346 &focus_handle,
3347 window,
3348 cx,
3349 )
3350 .map(|kb| kb.size(rems_from_px(10.))),
3351 )
3352 .tooltip(Tooltip::text(
3353 "Enable Burn Mode for unlimited tool use.",
3354 ))
3355 .on_click({
3356 cx.listener(move |this, _, _window, cx| {
3357 thread.update(cx, |thread, _cx| {
3358 thread.set_completion_mode(CompletionMode::Burn);
3359 });
3360 this.resume_chat(cx);
3361 })
3362 }),
3363 )
3364 })
3365 .child(
3366 Button::new("continue-conversation", "Continue")
3367 .layer(ElevationIndex::ModalSurface)
3368 .label_size(LabelSize::Small)
3369 .key_binding(
3370 KeyBinding::for_action_in(
3371 &ContinueThread,
3372 &focus_handle,
3373 window,
3374 cx,
3375 )
3376 .map(|kb| kb.size(rems_from_px(10.))),
3377 )
3378 .on_click(cx.listener(|this, _, _window, cx| {
3379 this.resume_chat(cx);
3380 })),
3381 ),
3382 ),
3383 )
3384 }
3385
3386 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
3387 let message = message.into();
3388
3389 IconButton::new("copy", IconName::Copy)
3390 .icon_size(IconSize::Small)
3391 .icon_color(Color::Muted)
3392 .tooltip(Tooltip::text("Copy Error Message"))
3393 .on_click(move |_, _, cx| {
3394 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
3395 })
3396 }
3397
3398 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3399 IconButton::new("dismiss", IconName::Close)
3400 .icon_size(IconSize::Small)
3401 .icon_color(Color::Muted)
3402 .tooltip(Tooltip::text("Dismiss Error"))
3403 .on_click(cx.listener({
3404 move |this, _, _, cx| {
3405 this.clear_thread_error(cx);
3406 cx.notify();
3407 }
3408 }))
3409 }
3410
3411 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3412 Button::new("upgrade", "Upgrade")
3413 .label_size(LabelSize::Small)
3414 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3415 .on_click(cx.listener({
3416 move |this, _, _, cx| {
3417 this.clear_thread_error(cx);
3418 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
3419 }
3420 }))
3421 }
3422}
3423
3424impl Focusable for AcpThreadView {
3425 fn focus_handle(&self, cx: &App) -> FocusHandle {
3426 self.message_editor.focus_handle(cx)
3427 }
3428}
3429
3430impl Render for AcpThreadView {
3431 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3432 let has_messages = self.list_state.item_count() > 0;
3433
3434 v_flex()
3435 .size_full()
3436 .key_context("AcpThread")
3437 .on_action(cx.listener(Self::open_agent_diff))
3438 .on_action(cx.listener(Self::toggle_burn_mode))
3439 .bg(cx.theme().colors().panel_background)
3440 .child(match &self.thread_state {
3441 ThreadState::Unauthenticated {
3442 connection,
3443 description,
3444 configuration_view,
3445 ..
3446 } => self.render_auth_required_state(
3447 connection,
3448 description.as_ref(),
3449 configuration_view.as_ref(),
3450 window,
3451 cx,
3452 ),
3453 ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
3454 ThreadState::LoadError(e) => v_flex()
3455 .p_2()
3456 .flex_1()
3457 .items_center()
3458 .justify_center()
3459 .child(self.render_load_error(e, cx)),
3460 ThreadState::ServerExited { status } => v_flex()
3461 .p_2()
3462 .flex_1()
3463 .items_center()
3464 .justify_center()
3465 .child(self.render_server_exited(*status, cx)),
3466 ThreadState::Ready { thread, .. } => {
3467 let thread_clone = thread.clone();
3468
3469 v_flex().flex_1().map(|this| {
3470 if has_messages {
3471 this.child(
3472 list(
3473 self.list_state.clone(),
3474 cx.processor(|this, index: usize, window, cx| {
3475 let Some((entry, len)) = this.thread().and_then(|thread| {
3476 let entries = &thread.read(cx).entries();
3477 Some((entries.get(index)?, entries.len()))
3478 }) else {
3479 return Empty.into_any();
3480 };
3481 this.render_entry(index, len, entry, window, cx)
3482 }),
3483 )
3484 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
3485 .flex_grow()
3486 .into_any(),
3487 )
3488 .child(self.render_vertical_scrollbar(cx))
3489 .children(
3490 match thread_clone.read(cx).status() {
3491 ThreadStatus::Idle
3492 | ThreadStatus::WaitingForToolConfirmation => None,
3493 ThreadStatus::Generating => div()
3494 .px_5()
3495 .py_2()
3496 .child(LoadingLabel::new("").size(LabelSize::Small))
3497 .into(),
3498 },
3499 )
3500 } else {
3501 this.child(self.render_empty_state(cx))
3502 }
3503 })
3504 }
3505 })
3506 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
3507 // above so that the scrollbar doesn't render behind it. The current setup allows
3508 // the scrollbar to stop exactly at the activity bar start.
3509 .when(has_messages, |this| match &self.thread_state {
3510 ThreadState::Ready { thread, .. } => {
3511 this.children(self.render_activity_bar(thread, window, cx))
3512 }
3513 _ => this,
3514 })
3515 .children(self.render_thread_error(window, cx))
3516 .child(self.render_message_editor(window, cx))
3517 }
3518}
3519
3520fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
3521 let theme_settings = ThemeSettings::get_global(cx);
3522 let colors = cx.theme().colors();
3523
3524 let buffer_font_size = TextSize::Small.rems(cx);
3525
3526 let mut text_style = window.text_style();
3527 let line_height = buffer_font_size * 1.75;
3528
3529 let font_family = if buffer_font {
3530 theme_settings.buffer_font.family.clone()
3531 } else {
3532 theme_settings.ui_font.family.clone()
3533 };
3534
3535 let font_size = if buffer_font {
3536 TextSize::Small.rems(cx)
3537 } else {
3538 TextSize::Default.rems(cx)
3539 };
3540
3541 text_style.refine(&TextStyleRefinement {
3542 font_family: Some(font_family),
3543 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
3544 font_features: Some(theme_settings.ui_font.features.clone()),
3545 font_size: Some(font_size.into()),
3546 line_height: Some(line_height.into()),
3547 color: Some(cx.theme().colors().text),
3548 ..Default::default()
3549 });
3550
3551 MarkdownStyle {
3552 base_text_style: text_style.clone(),
3553 syntax: cx.theme().syntax().clone(),
3554 selection_background_color: cx.theme().colors().element_selection_background,
3555 code_block_overflow_x_scroll: true,
3556 table_overflow_x_scroll: true,
3557 heading_level_styles: Some(HeadingLevelStyles {
3558 h1: Some(TextStyleRefinement {
3559 font_size: Some(rems(1.15).into()),
3560 ..Default::default()
3561 }),
3562 h2: Some(TextStyleRefinement {
3563 font_size: Some(rems(1.1).into()),
3564 ..Default::default()
3565 }),
3566 h3: Some(TextStyleRefinement {
3567 font_size: Some(rems(1.05).into()),
3568 ..Default::default()
3569 }),
3570 h4: Some(TextStyleRefinement {
3571 font_size: Some(rems(1.).into()),
3572 ..Default::default()
3573 }),
3574 h5: Some(TextStyleRefinement {
3575 font_size: Some(rems(0.95).into()),
3576 ..Default::default()
3577 }),
3578 h6: Some(TextStyleRefinement {
3579 font_size: Some(rems(0.875).into()),
3580 ..Default::default()
3581 }),
3582 }),
3583 code_block: StyleRefinement {
3584 padding: EdgesRefinement {
3585 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3586 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3587 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3588 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3589 },
3590 margin: EdgesRefinement {
3591 top: Some(Length::Definite(Pixels(8.).into())),
3592 left: Some(Length::Definite(Pixels(0.).into())),
3593 right: Some(Length::Definite(Pixels(0.).into())),
3594 bottom: Some(Length::Definite(Pixels(12.).into())),
3595 },
3596 border_style: Some(BorderStyle::Solid),
3597 border_widths: EdgesRefinement {
3598 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
3599 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
3600 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
3601 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
3602 },
3603 border_color: Some(colors.border_variant),
3604 background: Some(colors.editor_background.into()),
3605 text: Some(TextStyleRefinement {
3606 font_family: Some(theme_settings.buffer_font.family.clone()),
3607 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3608 font_features: Some(theme_settings.buffer_font.features.clone()),
3609 font_size: Some(buffer_font_size.into()),
3610 ..Default::default()
3611 }),
3612 ..Default::default()
3613 },
3614 inline_code: TextStyleRefinement {
3615 font_family: Some(theme_settings.buffer_font.family.clone()),
3616 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3617 font_features: Some(theme_settings.buffer_font.features.clone()),
3618 font_size: Some(buffer_font_size.into()),
3619 background_color: Some(colors.editor_foreground.opacity(0.08)),
3620 ..Default::default()
3621 },
3622 link: TextStyleRefinement {
3623 background_color: Some(colors.editor_foreground.opacity(0.025)),
3624 underline: Some(UnderlineStyle {
3625 color: Some(colors.text_accent.opacity(0.5)),
3626 thickness: px(1.),
3627 ..Default::default()
3628 }),
3629 ..Default::default()
3630 },
3631 ..Default::default()
3632 }
3633}
3634
3635fn plan_label_markdown_style(
3636 status: &acp::PlanEntryStatus,
3637 window: &Window,
3638 cx: &App,
3639) -> MarkdownStyle {
3640 let default_md_style = default_markdown_style(false, window, cx);
3641
3642 MarkdownStyle {
3643 base_text_style: TextStyle {
3644 color: cx.theme().colors().text_muted,
3645 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
3646 Some(gpui::StrikethroughStyle {
3647 thickness: px(1.),
3648 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
3649 })
3650 } else {
3651 None
3652 },
3653 ..default_md_style.base_text_style
3654 },
3655 ..default_md_style
3656 }
3657}
3658
3659fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
3660 let default_md_style = default_markdown_style(true, window, cx);
3661
3662 MarkdownStyle {
3663 base_text_style: TextStyle {
3664 ..default_md_style.base_text_style
3665 },
3666 selection_background_color: cx.theme().colors().element_selection_background,
3667 ..Default::default()
3668 }
3669}
3670
3671#[cfg(test)]
3672pub(crate) mod tests {
3673 use acp_thread::StubAgentConnection;
3674 use agent::{TextThreadStore, ThreadStore};
3675 use agent_client_protocol::SessionId;
3676 use editor::EditorSettings;
3677 use fs::FakeFs;
3678 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
3679 use project::Project;
3680 use serde_json::json;
3681 use settings::SettingsStore;
3682 use std::any::Any;
3683 use std::path::Path;
3684 use workspace::Item;
3685
3686 use super::*;
3687
3688 #[gpui::test]
3689 async fn test_drop(cx: &mut TestAppContext) {
3690 init_test(cx);
3691
3692 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3693 let weak_view = thread_view.downgrade();
3694 drop(thread_view);
3695 assert!(!weak_view.is_upgradable());
3696 }
3697
3698 #[gpui::test]
3699 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3700 init_test(cx);
3701
3702 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3703
3704 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3705 message_editor.update_in(cx, |editor, window, cx| {
3706 editor.set_text("Hello", window, cx);
3707 });
3708
3709 cx.deactivate_window();
3710
3711 thread_view.update_in(cx, |thread_view, window, cx| {
3712 thread_view.send(window, cx);
3713 });
3714
3715 cx.run_until_parked();
3716
3717 assert!(
3718 cx.windows()
3719 .iter()
3720 .any(|window| window.downcast::<AgentNotification>().is_some())
3721 );
3722 }
3723
3724 #[gpui::test]
3725 async fn test_notification_for_error(cx: &mut TestAppContext) {
3726 init_test(cx);
3727
3728 let (thread_view, cx) =
3729 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
3730
3731 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3732 message_editor.update_in(cx, |editor, window, cx| {
3733 editor.set_text("Hello", window, cx);
3734 });
3735
3736 cx.deactivate_window();
3737
3738 thread_view.update_in(cx, |thread_view, window, cx| {
3739 thread_view.send(window, cx);
3740 });
3741
3742 cx.run_until_parked();
3743
3744 assert!(
3745 cx.windows()
3746 .iter()
3747 .any(|window| window.downcast::<AgentNotification>().is_some())
3748 );
3749 }
3750
3751 #[gpui::test]
3752 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3753 init_test(cx);
3754
3755 let tool_call_id = acp::ToolCallId("1".into());
3756 let tool_call = acp::ToolCall {
3757 id: tool_call_id.clone(),
3758 title: "Label".into(),
3759 kind: acp::ToolKind::Edit,
3760 status: acp::ToolCallStatus::Pending,
3761 content: vec!["hi".into()],
3762 locations: vec![],
3763 raw_input: None,
3764 raw_output: None,
3765 };
3766 let connection =
3767 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3768 tool_call_id,
3769 vec![acp::PermissionOption {
3770 id: acp::PermissionOptionId("1".into()),
3771 name: "Allow".into(),
3772 kind: acp::PermissionOptionKind::AllowOnce,
3773 }],
3774 )]));
3775
3776 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3777
3778 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3779
3780 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3781 message_editor.update_in(cx, |editor, window, cx| {
3782 editor.set_text("Hello", window, cx);
3783 });
3784
3785 cx.deactivate_window();
3786
3787 thread_view.update_in(cx, |thread_view, window, cx| {
3788 thread_view.send(window, cx);
3789 });
3790
3791 cx.run_until_parked();
3792
3793 assert!(
3794 cx.windows()
3795 .iter()
3796 .any(|window| window.downcast::<AgentNotification>().is_some())
3797 );
3798 }
3799
3800 async fn setup_thread_view(
3801 agent: impl AgentServer + 'static,
3802 cx: &mut TestAppContext,
3803 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
3804 let fs = FakeFs::new(cx.executor());
3805 let project = Project::test(fs, [], cx).await;
3806 let (workspace, cx) =
3807 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3808
3809 let thread_store =
3810 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
3811 let text_thread_store =
3812 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
3813
3814 let thread_view = cx.update(|window, cx| {
3815 cx.new(|cx| {
3816 AcpThreadView::new(
3817 Rc::new(agent),
3818 workspace.downgrade(),
3819 project,
3820 thread_store.clone(),
3821 text_thread_store.clone(),
3822 window,
3823 cx,
3824 )
3825 })
3826 });
3827 cx.run_until_parked();
3828 (thread_view, cx)
3829 }
3830
3831 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
3832 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3833
3834 workspace
3835 .update_in(cx, |workspace, window, cx| {
3836 workspace.add_item_to_active_pane(
3837 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
3838 None,
3839 true,
3840 window,
3841 cx,
3842 );
3843 })
3844 .unwrap();
3845 }
3846
3847 struct ThreadViewItem(Entity<AcpThreadView>);
3848
3849 impl Item for ThreadViewItem {
3850 type Event = ();
3851
3852 fn include_in_nav_history() -> bool {
3853 false
3854 }
3855
3856 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3857 "Test".into()
3858 }
3859 }
3860
3861 impl EventEmitter<()> for ThreadViewItem {}
3862
3863 impl Focusable for ThreadViewItem {
3864 fn focus_handle(&self, cx: &App) -> FocusHandle {
3865 self.0.read(cx).focus_handle(cx).clone()
3866 }
3867 }
3868
3869 impl Render for ThreadViewItem {
3870 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3871 self.0.clone().into_any_element()
3872 }
3873 }
3874
3875 struct StubAgentServer<C> {
3876 connection: C,
3877 }
3878
3879 impl<C> StubAgentServer<C> {
3880 fn new(connection: C) -> Self {
3881 Self { connection }
3882 }
3883 }
3884
3885 impl StubAgentServer<StubAgentConnection> {
3886 fn default_response() -> Self {
3887 let conn = StubAgentConnection::new();
3888 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
3889 content: "Default response".into(),
3890 }]);
3891 Self::new(conn)
3892 }
3893 }
3894
3895 impl<C> AgentServer for StubAgentServer<C>
3896 where
3897 C: 'static + AgentConnection + Send + Clone,
3898 {
3899 fn logo(&self) -> ui::IconName {
3900 ui::IconName::Ai
3901 }
3902
3903 fn name(&self) -> &'static str {
3904 "Test"
3905 }
3906
3907 fn empty_state_headline(&self) -> &'static str {
3908 "Test"
3909 }
3910
3911 fn empty_state_message(&self) -> &'static str {
3912 "Test"
3913 }
3914
3915 fn connect(
3916 &self,
3917 _root_dir: &Path,
3918 _project: &Entity<Project>,
3919 _cx: &mut App,
3920 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3921 Task::ready(Ok(Rc::new(self.connection.clone())))
3922 }
3923 }
3924
3925 #[derive(Clone)]
3926 struct SaboteurAgentConnection;
3927
3928 impl AgentConnection for SaboteurAgentConnection {
3929 fn new_thread(
3930 self: Rc<Self>,
3931 project: Entity<Project>,
3932 _cwd: &Path,
3933 cx: &mut gpui::App,
3934 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3935 Task::ready(Ok(cx.new(|cx| {
3936 AcpThread::new(
3937 "SaboteurAgentConnection",
3938 self,
3939 project,
3940 SessionId("test".into()),
3941 cx,
3942 )
3943 })))
3944 }
3945
3946 fn auth_methods(&self) -> &[acp::AuthMethod] {
3947 &[]
3948 }
3949
3950 fn authenticate(
3951 &self,
3952 _method_id: acp::AuthMethodId,
3953 _cx: &mut App,
3954 ) -> Task<gpui::Result<()>> {
3955 unimplemented!()
3956 }
3957
3958 fn prompt(
3959 &self,
3960 _id: Option<acp_thread::UserMessageId>,
3961 _params: acp::PromptRequest,
3962 _cx: &mut App,
3963 ) -> Task<gpui::Result<acp::PromptResponse>> {
3964 Task::ready(Err(anyhow::anyhow!("Error prompting")))
3965 }
3966
3967 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3968 unimplemented!()
3969 }
3970
3971 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3972 self
3973 }
3974 }
3975
3976 pub(crate) fn init_test(cx: &mut TestAppContext) {
3977 cx.update(|cx| {
3978 let settings_store = SettingsStore::test(cx);
3979 cx.set_global(settings_store);
3980 language::init(cx);
3981 Project::init_settings(cx);
3982 AgentSettings::register(cx);
3983 workspace::init_settings(cx);
3984 ThemeSettings::register(cx);
3985 release_channel::init(SemanticVersion::default(), cx);
3986 EditorSettings::register(cx);
3987 });
3988 }
3989
3990 #[gpui::test]
3991 async fn test_rewind_views(cx: &mut TestAppContext) {
3992 init_test(cx);
3993
3994 let fs = FakeFs::new(cx.executor());
3995 fs.insert_tree(
3996 "/project",
3997 json!({
3998 "test1.txt": "old content 1",
3999 "test2.txt": "old content 2"
4000 }),
4001 )
4002 .await;
4003 let project = Project::test(fs, [Path::new("/project")], cx).await;
4004 let (workspace, cx) =
4005 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
4006
4007 let thread_store =
4008 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
4009 let text_thread_store =
4010 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
4011
4012 let connection = Rc::new(StubAgentConnection::new());
4013 let thread_view = cx.update(|window, cx| {
4014 cx.new(|cx| {
4015 AcpThreadView::new(
4016 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4017 workspace.downgrade(),
4018 project.clone(),
4019 thread_store.clone(),
4020 text_thread_store.clone(),
4021 window,
4022 cx,
4023 )
4024 })
4025 });
4026
4027 cx.run_until_parked();
4028
4029 let thread = thread_view
4030 .read_with(cx, |view, _| view.thread().cloned())
4031 .unwrap();
4032
4033 // First user message
4034 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4035 id: acp::ToolCallId("tool1".into()),
4036 title: "Edit file 1".into(),
4037 kind: acp::ToolKind::Edit,
4038 status: acp::ToolCallStatus::Completed,
4039 content: vec![acp::ToolCallContent::Diff {
4040 diff: acp::Diff {
4041 path: "/project/test1.txt".into(),
4042 old_text: Some("old content 1".into()),
4043 new_text: "new content 1".into(),
4044 },
4045 }],
4046 locations: vec![],
4047 raw_input: None,
4048 raw_output: None,
4049 })]);
4050
4051 thread
4052 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4053 .await
4054 .unwrap();
4055 cx.run_until_parked();
4056
4057 thread.read_with(cx, |thread, _| {
4058 assert_eq!(thread.entries().len(), 2);
4059 });
4060
4061 thread_view.read_with(cx, |view, cx| {
4062 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4063 assert!(
4064 entry_view_state
4065 .entry(0)
4066 .unwrap()
4067 .message_editor()
4068 .is_some()
4069 );
4070 assert!(entry_view_state.entry(1).unwrap().has_content());
4071 });
4072 });
4073
4074 // Second user message
4075 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4076 id: acp::ToolCallId("tool2".into()),
4077 title: "Edit file 2".into(),
4078 kind: acp::ToolKind::Edit,
4079 status: acp::ToolCallStatus::Completed,
4080 content: vec![acp::ToolCallContent::Diff {
4081 diff: acp::Diff {
4082 path: "/project/test2.txt".into(),
4083 old_text: Some("old content 2".into()),
4084 new_text: "new content 2".into(),
4085 },
4086 }],
4087 locations: vec![],
4088 raw_input: None,
4089 raw_output: None,
4090 })]);
4091
4092 thread
4093 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4094 .await
4095 .unwrap();
4096 cx.run_until_parked();
4097
4098 let second_user_message_id = thread.read_with(cx, |thread, _| {
4099 assert_eq!(thread.entries().len(), 4);
4100 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4101 panic!();
4102 };
4103 user_message.id.clone().unwrap()
4104 });
4105
4106 thread_view.read_with(cx, |view, cx| {
4107 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4108 assert!(
4109 entry_view_state
4110 .entry(0)
4111 .unwrap()
4112 .message_editor()
4113 .is_some()
4114 );
4115 assert!(entry_view_state.entry(1).unwrap().has_content());
4116 assert!(
4117 entry_view_state
4118 .entry(2)
4119 .unwrap()
4120 .message_editor()
4121 .is_some()
4122 );
4123 assert!(entry_view_state.entry(3).unwrap().has_content());
4124 });
4125 });
4126
4127 // Rewind to first message
4128 thread
4129 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4130 .await
4131 .unwrap();
4132
4133 cx.run_until_parked();
4134
4135 thread.read_with(cx, |thread, _| {
4136 assert_eq!(thread.entries().len(), 2);
4137 });
4138
4139 thread_view.read_with(cx, |view, cx| {
4140 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4141 assert!(
4142 entry_view_state
4143 .entry(0)
4144 .unwrap()
4145 .message_editor()
4146 .is_some()
4147 );
4148 assert!(entry_view_state.entry(1).unwrap().has_content());
4149
4150 // Old views should be dropped
4151 assert!(entry_view_state.entry(2).is_none());
4152 assert!(entry_view_state.entry(3).is_none());
4153 });
4154 });
4155 }
4156
4157 #[gpui::test]
4158 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4159 init_test(cx);
4160
4161 let connection = StubAgentConnection::new();
4162
4163 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4164 content: acp::ContentBlock::Text(acp::TextContent {
4165 text: "Response".into(),
4166 annotations: None,
4167 }),
4168 }]);
4169
4170 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4171 add_to_workspace(thread_view.clone(), cx);
4172
4173 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4174 message_editor.update_in(cx, |editor, window, cx| {
4175 editor.set_text("Original message to edit", window, cx);
4176 });
4177 thread_view.update_in(cx, |thread_view, window, cx| {
4178 thread_view.send(window, cx);
4179 });
4180
4181 cx.run_until_parked();
4182
4183 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4184 assert_eq!(view.editing_message, None);
4185
4186 view.entry_view_state
4187 .read(cx)
4188 .entry(0)
4189 .unwrap()
4190 .message_editor()
4191 .unwrap()
4192 .clone()
4193 });
4194
4195 // Focus
4196 cx.focus(&user_message_editor);
4197 thread_view.read_with(cx, |view, _cx| {
4198 assert_eq!(view.editing_message, Some(0));
4199 });
4200
4201 // Edit
4202 user_message_editor.update_in(cx, |editor, window, cx| {
4203 editor.set_text("Edited message content", window, cx);
4204 });
4205
4206 // Cancel
4207 user_message_editor.update_in(cx, |_editor, window, cx| {
4208 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4209 });
4210
4211 thread_view.read_with(cx, |view, _cx| {
4212 assert_eq!(view.editing_message, None);
4213 });
4214
4215 user_message_editor.read_with(cx, |editor, cx| {
4216 assert_eq!(editor.text(cx), "Original message to edit");
4217 });
4218 }
4219
4220 #[gpui::test]
4221 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4222 init_test(cx);
4223
4224 let connection = StubAgentConnection::new();
4225
4226 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4227 content: acp::ContentBlock::Text(acp::TextContent {
4228 text: "Response".into(),
4229 annotations: None,
4230 }),
4231 }]);
4232
4233 let (thread_view, cx) =
4234 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4235 add_to_workspace(thread_view.clone(), cx);
4236
4237 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4238 message_editor.update_in(cx, |editor, window, cx| {
4239 editor.set_text("Original message to edit", window, cx);
4240 });
4241 thread_view.update_in(cx, |thread_view, window, cx| {
4242 thread_view.send(window, cx);
4243 });
4244
4245 cx.run_until_parked();
4246
4247 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4248 assert_eq!(view.editing_message, None);
4249 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
4250
4251 view.entry_view_state
4252 .read(cx)
4253 .entry(0)
4254 .unwrap()
4255 .message_editor()
4256 .unwrap()
4257 .clone()
4258 });
4259
4260 // Focus
4261 cx.focus(&user_message_editor);
4262
4263 // Edit
4264 user_message_editor.update_in(cx, |editor, window, cx| {
4265 editor.set_text("Edited message content", window, cx);
4266 });
4267
4268 // Send
4269 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4270 content: acp::ContentBlock::Text(acp::TextContent {
4271 text: "New Response".into(),
4272 annotations: None,
4273 }),
4274 }]);
4275
4276 user_message_editor.update_in(cx, |_editor, window, cx| {
4277 window.dispatch_action(Box::new(Chat), cx);
4278 });
4279
4280 cx.run_until_parked();
4281
4282 thread_view.read_with(cx, |view, cx| {
4283 assert_eq!(view.editing_message, None);
4284
4285 let entries = view.thread().unwrap().read(cx).entries();
4286 assert_eq!(entries.len(), 2);
4287 assert_eq!(
4288 entries[0].to_markdown(cx),
4289 "## User\n\nEdited message content\n\n"
4290 );
4291 assert_eq!(
4292 entries[1].to_markdown(cx),
4293 "## Assistant\n\nNew Response\n\n"
4294 );
4295
4296 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
4297 assert!(!state.entry(1).unwrap().has_content());
4298 state.entry(0).unwrap().message_editor().unwrap().clone()
4299 });
4300
4301 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4302 })
4303 }
4304
4305 #[gpui::test]
4306 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4307 init_test(cx);
4308
4309 let connection = StubAgentConnection::new();
4310
4311 let (thread_view, cx) =
4312 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4313 add_to_workspace(thread_view.clone(), cx);
4314
4315 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4316 message_editor.update_in(cx, |editor, window, cx| {
4317 editor.set_text("Original message to edit", window, cx);
4318 });
4319 thread_view.update_in(cx, |thread_view, window, cx| {
4320 thread_view.send(window, cx);
4321 });
4322
4323 cx.run_until_parked();
4324
4325 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
4326 let thread = view.thread().unwrap().read(cx);
4327 assert_eq!(thread.entries().len(), 1);
4328
4329 let editor = view
4330 .entry_view_state
4331 .read(cx)
4332 .entry(0)
4333 .unwrap()
4334 .message_editor()
4335 .unwrap()
4336 .clone();
4337
4338 (editor, thread.session_id().clone())
4339 });
4340
4341 // Focus
4342 cx.focus(&user_message_editor);
4343
4344 thread_view.read_with(cx, |view, _cx| {
4345 assert_eq!(view.editing_message, Some(0));
4346 });
4347
4348 // Edit
4349 user_message_editor.update_in(cx, |editor, window, cx| {
4350 editor.set_text("Edited message content", window, cx);
4351 });
4352
4353 thread_view.read_with(cx, |view, _cx| {
4354 assert_eq!(view.editing_message, Some(0));
4355 });
4356
4357 // Finish streaming response
4358 cx.update(|_, cx| {
4359 connection.send_update(
4360 session_id.clone(),
4361 acp::SessionUpdate::AgentMessageChunk {
4362 content: acp::ContentBlock::Text(acp::TextContent {
4363 text: "Response".into(),
4364 annotations: None,
4365 }),
4366 },
4367 cx,
4368 );
4369 connection.end_turn(session_id, acp::StopReason::EndTurn);
4370 });
4371
4372 thread_view.read_with(cx, |view, _cx| {
4373 assert_eq!(view.editing_message, Some(0));
4374 });
4375
4376 cx.run_until_parked();
4377
4378 // Should still be editing
4379 cx.update(|window, cx| {
4380 assert!(user_message_editor.focus_handle(cx).is_focused(window));
4381 assert_eq!(thread_view.read(cx).editing_message, Some(0));
4382 assert_eq!(
4383 user_message_editor.read(cx).text(cx),
4384 "Edited message content"
4385 );
4386 });
4387 }
4388
4389 #[gpui::test]
4390 async fn test_interrupt(cx: &mut TestAppContext) {
4391 init_test(cx);
4392
4393 let connection = StubAgentConnection::new();
4394
4395 let (thread_view, cx) =
4396 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4397 add_to_workspace(thread_view.clone(), cx);
4398
4399 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4400 message_editor.update_in(cx, |editor, window, cx| {
4401 editor.set_text("Message 1", window, cx);
4402 });
4403 thread_view.update_in(cx, |thread_view, window, cx| {
4404 thread_view.send(window, cx);
4405 });
4406
4407 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4408 let thread = view.thread().unwrap();
4409
4410 (thread.clone(), thread.read(cx).session_id().clone())
4411 });
4412
4413 cx.run_until_parked();
4414
4415 cx.update(|_, cx| {
4416 connection.send_update(
4417 session_id.clone(),
4418 acp::SessionUpdate::AgentMessageChunk {
4419 content: "Message 1 resp".into(),
4420 },
4421 cx,
4422 );
4423 });
4424
4425 cx.run_until_parked();
4426
4427 thread.read_with(cx, |thread, cx| {
4428 assert_eq!(
4429 thread.to_markdown(cx),
4430 indoc::indoc! {"
4431 ## User
4432
4433 Message 1
4434
4435 ## Assistant
4436
4437 Message 1 resp
4438
4439 "}
4440 )
4441 });
4442
4443 message_editor.update_in(cx, |editor, window, cx| {
4444 editor.set_text("Message 2", window, cx);
4445 });
4446 thread_view.update_in(cx, |thread_view, window, cx| {
4447 thread_view.send(window, cx);
4448 });
4449
4450 cx.update(|_, cx| {
4451 // Simulate a response sent after beginning to cancel
4452 connection.send_update(
4453 session_id.clone(),
4454 acp::SessionUpdate::AgentMessageChunk {
4455 content: "onse".into(),
4456 },
4457 cx,
4458 );
4459 });
4460
4461 cx.run_until_parked();
4462
4463 // Last Message 1 response should appear before Message 2
4464 thread.read_with(cx, |thread, cx| {
4465 assert_eq!(
4466 thread.to_markdown(cx),
4467 indoc::indoc! {"
4468 ## User
4469
4470 Message 1
4471
4472 ## Assistant
4473
4474 Message 1 response
4475
4476 ## User
4477
4478 Message 2
4479
4480 "}
4481 )
4482 });
4483
4484 cx.update(|_, cx| {
4485 connection.send_update(
4486 session_id.clone(),
4487 acp::SessionUpdate::AgentMessageChunk {
4488 content: "Message 2 response".into(),
4489 },
4490 cx,
4491 );
4492 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4493 });
4494
4495 cx.run_until_parked();
4496
4497 thread.read_with(cx, |thread, cx| {
4498 assert_eq!(
4499 thread.to_markdown(cx),
4500 indoc::indoc! {"
4501 ## User
4502
4503 Message 1
4504
4505 ## Assistant
4506
4507 Message 1 response
4508
4509 ## User
4510
4511 Message 2
4512
4513 ## Assistant
4514
4515 Message 2 response
4516
4517 "}
4518 )
4519 });
4520 }
4521}