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) =
2796 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
2797 else {
2798 return;
2799 };
2800
2801 workspace
2802 .open_path(path, None, true, window, cx)
2803 .detach_and_log_err(cx);
2804 }
2805 MentionUri::Directory { abs_path } => {
2806 let project = workspace.project();
2807 let Some(entry) = project.update(cx, |project, cx| {
2808 let path = project.find_project_path(abs_path, cx)?;
2809 project.entry_for_path(&path, cx)
2810 }) else {
2811 return;
2812 };
2813
2814 project.update(cx, |_, cx| {
2815 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2816 });
2817 }
2818 MentionUri::Symbol {
2819 path, line_range, ..
2820 }
2821 | MentionUri::Selection { path, line_range } => {
2822 let project = workspace.project();
2823 let Some((path, _)) = project.update(cx, |project, cx| {
2824 let path = project.find_project_path(path, cx)?;
2825 let entry = project.entry_for_path(&path, cx)?;
2826 Some((path, entry))
2827 }) else {
2828 return;
2829 };
2830
2831 let item = workspace.open_path(path, None, true, window, cx);
2832 window
2833 .spawn(cx, async move |cx| {
2834 let Some(editor) = item.await?.downcast::<Editor>() else {
2835 return Ok(());
2836 };
2837 let range =
2838 Point::new(line_range.start, 0)..Point::new(line_range.start, 0);
2839 editor
2840 .update_in(cx, |editor, window, cx| {
2841 editor.change_selections(
2842 SelectionEffects::scroll(Autoscroll::center()),
2843 window,
2844 cx,
2845 |s| s.select_ranges(vec![range]),
2846 );
2847 })
2848 .ok();
2849 anyhow::Ok(())
2850 })
2851 .detach_and_log_err(cx);
2852 }
2853 MentionUri::Thread { id, .. } => {
2854 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2855 panel.update(cx, |panel, cx| {
2856 panel
2857 .open_thread_by_id(&id, window, cx)
2858 .detach_and_log_err(cx)
2859 });
2860 }
2861 }
2862 MentionUri::TextThread { path, .. } => {
2863 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2864 panel.update(cx, |panel, cx| {
2865 panel
2866 .open_saved_prompt_editor(path.as_path().into(), window, cx)
2867 .detach_and_log_err(cx);
2868 });
2869 }
2870 }
2871 MentionUri::Rule { id, .. } => {
2872 let PromptId::User { uuid } = id else {
2873 return;
2874 };
2875 window.dispatch_action(
2876 Box::new(OpenRulesLibrary {
2877 prompt_to_select: Some(uuid.0),
2878 }),
2879 cx,
2880 )
2881 }
2882 MentionUri::Fetch { url } => {
2883 cx.open_url(url.as_str());
2884 }
2885 })
2886 } else {
2887 cx.open_url(&url);
2888 }
2889 }
2890
2891 fn open_tool_call_location(
2892 &self,
2893 entry_ix: usize,
2894 location_ix: usize,
2895 window: &mut Window,
2896 cx: &mut Context<Self>,
2897 ) -> Option<()> {
2898 let (tool_call_location, agent_location) = self
2899 .thread()?
2900 .read(cx)
2901 .entries()
2902 .get(entry_ix)?
2903 .location(location_ix)?;
2904
2905 let project_path = self
2906 .project
2907 .read(cx)
2908 .find_project_path(&tool_call_location.path, cx)?;
2909
2910 let open_task = self
2911 .workspace
2912 .update(cx, |workspace, cx| {
2913 workspace.open_path(project_path, None, true, window, cx)
2914 })
2915 .log_err()?;
2916 window
2917 .spawn(cx, async move |cx| {
2918 let item = open_task.await?;
2919
2920 let Some(active_editor) = item.downcast::<Editor>() else {
2921 return anyhow::Ok(());
2922 };
2923
2924 active_editor.update_in(cx, |editor, window, cx| {
2925 let multibuffer = editor.buffer().read(cx);
2926 let buffer = multibuffer.as_singleton();
2927 if agent_location.buffer.upgrade() == buffer {
2928 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
2929 let anchor = editor::Anchor::in_buffer(
2930 excerpt_id.unwrap(),
2931 buffer.unwrap().read(cx).remote_id(),
2932 agent_location.position,
2933 );
2934 editor.change_selections(Default::default(), window, cx, |selections| {
2935 selections.select_anchor_ranges([anchor..anchor]);
2936 })
2937 } else {
2938 let row = tool_call_location.line.unwrap_or_default();
2939 editor.change_selections(Default::default(), window, cx, |selections| {
2940 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
2941 })
2942 }
2943 })?;
2944
2945 anyhow::Ok(())
2946 })
2947 .detach_and_log_err(cx);
2948
2949 None
2950 }
2951
2952 pub fn open_thread_as_markdown(
2953 &self,
2954 workspace: Entity<Workspace>,
2955 window: &mut Window,
2956 cx: &mut App,
2957 ) -> Task<anyhow::Result<()>> {
2958 let markdown_language_task = workspace
2959 .read(cx)
2960 .app_state()
2961 .languages
2962 .language_for_name("Markdown");
2963
2964 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
2965 let thread = thread.read(cx);
2966 (thread.title().to_string(), thread.to_markdown(cx))
2967 } else {
2968 return Task::ready(Ok(()));
2969 };
2970
2971 window.spawn(cx, async move |cx| {
2972 let markdown_language = markdown_language_task.await?;
2973
2974 workspace.update_in(cx, |workspace, window, cx| {
2975 let project = workspace.project().clone();
2976
2977 if !project.read(cx).is_local() {
2978 bail!("failed to open active thread as markdown in remote project");
2979 }
2980
2981 let buffer = project.update(cx, |project, cx| {
2982 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2983 });
2984 let buffer = cx.new(|cx| {
2985 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2986 });
2987
2988 workspace.add_item_to_active_pane(
2989 Box::new(cx.new(|cx| {
2990 let mut editor =
2991 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2992 editor.set_breadcrumb_header(thread_summary);
2993 editor
2994 })),
2995 None,
2996 true,
2997 window,
2998 cx,
2999 );
3000
3001 anyhow::Ok(())
3002 })??;
3003 anyhow::Ok(())
3004 })
3005 }
3006
3007 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
3008 self.list_state.scroll_to(ListOffset::default());
3009 cx.notify();
3010 }
3011
3012 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
3013 if let Some(thread) = self.thread() {
3014 let entry_count = thread.read(cx).entries().len();
3015 self.list_state.reset(entry_count);
3016 cx.notify();
3017 }
3018 }
3019
3020 fn notify_with_sound(
3021 &mut self,
3022 caption: impl Into<SharedString>,
3023 icon: IconName,
3024 window: &mut Window,
3025 cx: &mut Context<Self>,
3026 ) {
3027 self.play_notification_sound(window, cx);
3028 self.show_notification(caption, icon, window, cx);
3029 }
3030
3031 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
3032 let settings = AgentSettings::get_global(cx);
3033 if settings.play_sound_when_agent_done && !window.is_window_active() {
3034 Audio::play_sound(Sound::AgentDone, cx);
3035 }
3036 }
3037
3038 fn show_notification(
3039 &mut self,
3040 caption: impl Into<SharedString>,
3041 icon: IconName,
3042 window: &mut Window,
3043 cx: &mut Context<Self>,
3044 ) {
3045 if window.is_window_active() || !self.notifications.is_empty() {
3046 return;
3047 }
3048
3049 let title = self.title(cx);
3050
3051 match AgentSettings::get_global(cx).notify_when_agent_waiting {
3052 NotifyWhenAgentWaiting::PrimaryScreen => {
3053 if let Some(primary) = cx.primary_display() {
3054 self.pop_up(icon, caption.into(), title, window, primary, cx);
3055 }
3056 }
3057 NotifyWhenAgentWaiting::AllScreens => {
3058 let caption = caption.into();
3059 for screen in cx.displays() {
3060 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
3061 }
3062 }
3063 NotifyWhenAgentWaiting::Never => {
3064 // Don't show anything
3065 }
3066 }
3067 }
3068
3069 fn pop_up(
3070 &mut self,
3071 icon: IconName,
3072 caption: SharedString,
3073 title: SharedString,
3074 window: &mut Window,
3075 screen: Rc<dyn PlatformDisplay>,
3076 cx: &mut Context<Self>,
3077 ) {
3078 let options = AgentNotification::window_options(screen, cx);
3079
3080 let project_name = self.workspace.upgrade().and_then(|workspace| {
3081 workspace
3082 .read(cx)
3083 .project()
3084 .read(cx)
3085 .visible_worktrees(cx)
3086 .next()
3087 .map(|worktree| worktree.read(cx).root_name().to_string())
3088 });
3089
3090 if let Some(screen_window) = cx
3091 .open_window(options, |_, cx| {
3092 cx.new(|_| {
3093 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
3094 })
3095 })
3096 .log_err()
3097 {
3098 if let Some(pop_up) = screen_window.entity(cx).log_err() {
3099 self.notification_subscriptions
3100 .entry(screen_window)
3101 .or_insert_with(Vec::new)
3102 .push(cx.subscribe_in(&pop_up, window, {
3103 |this, _, event, window, cx| match event {
3104 AgentNotificationEvent::Accepted => {
3105 let handle = window.window_handle();
3106 cx.activate(true);
3107
3108 let workspace_handle = this.workspace.clone();
3109
3110 // If there are multiple Zed windows, activate the correct one.
3111 cx.defer(move |cx| {
3112 handle
3113 .update(cx, |_view, window, _cx| {
3114 window.activate_window();
3115
3116 if let Some(workspace) = workspace_handle.upgrade() {
3117 workspace.update(_cx, |workspace, cx| {
3118 workspace.focus_panel::<AgentPanel>(window, cx);
3119 });
3120 }
3121 })
3122 .log_err();
3123 });
3124
3125 this.dismiss_notifications(cx);
3126 }
3127 AgentNotificationEvent::Dismissed => {
3128 this.dismiss_notifications(cx);
3129 }
3130 }
3131 }));
3132
3133 self.notifications.push(screen_window);
3134
3135 // If the user manually refocuses the original window, dismiss the popup.
3136 self.notification_subscriptions
3137 .entry(screen_window)
3138 .or_insert_with(Vec::new)
3139 .push({
3140 let pop_up_weak = pop_up.downgrade();
3141
3142 cx.observe_window_activation(window, move |_, window, cx| {
3143 if window.is_window_active() {
3144 if let Some(pop_up) = pop_up_weak.upgrade() {
3145 pop_up.update(cx, |_, cx| {
3146 cx.emit(AgentNotificationEvent::Dismissed);
3147 });
3148 }
3149 }
3150 })
3151 });
3152 }
3153 }
3154 }
3155
3156 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
3157 for window in self.notifications.drain(..) {
3158 window
3159 .update(cx, |_, window, _| {
3160 window.remove_window();
3161 })
3162 .ok();
3163
3164 self.notification_subscriptions.remove(&window);
3165 }
3166 }
3167
3168 fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement {
3169 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
3170 .shape(ui::IconButtonShape::Square)
3171 .icon_size(IconSize::Small)
3172 .icon_color(Color::Ignored)
3173 .tooltip(Tooltip::text("Open Thread as Markdown"))
3174 .on_click(cx.listener(move |this, _, window, cx| {
3175 if let Some(workspace) = this.workspace.upgrade() {
3176 this.open_thread_as_markdown(workspace, window, cx)
3177 .detach_and_log_err(cx);
3178 }
3179 }));
3180
3181 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
3182 .shape(ui::IconButtonShape::Square)
3183 .icon_size(IconSize::Small)
3184 .icon_color(Color::Ignored)
3185 .tooltip(Tooltip::text("Scroll To Top"))
3186 .on_click(cx.listener(move |this, _, _, cx| {
3187 this.scroll_to_top(cx);
3188 }));
3189
3190 h_flex()
3191 .w_full()
3192 .mr_1()
3193 .pb_2()
3194 .px(RESPONSE_PADDING_X)
3195 .opacity(0.4)
3196 .hover(|style| style.opacity(1.))
3197 .flex_wrap()
3198 .justify_end()
3199 .child(open_as_markdown)
3200 .child(scroll_to_top)
3201 }
3202
3203 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
3204 div()
3205 .id("acp-thread-scrollbar")
3206 .occlude()
3207 .on_mouse_move(cx.listener(|_, _, _, cx| {
3208 cx.notify();
3209 cx.stop_propagation()
3210 }))
3211 .on_hover(|_, _, cx| {
3212 cx.stop_propagation();
3213 })
3214 .on_any_mouse_down(|_, _, cx| {
3215 cx.stop_propagation();
3216 })
3217 .on_mouse_up(
3218 MouseButton::Left,
3219 cx.listener(|_, _, _, cx| {
3220 cx.stop_propagation();
3221 }),
3222 )
3223 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3224 cx.notify();
3225 }))
3226 .h_full()
3227 .absolute()
3228 .right_1()
3229 .top_1()
3230 .bottom_0()
3231 .w(px(12.))
3232 .cursor_default()
3233 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
3234 }
3235
3236 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3237 self.entry_view_state.update(cx, |entry_view_state, cx| {
3238 entry_view_state.settings_changed(cx);
3239 });
3240 }
3241
3242 pub(crate) fn insert_dragged_files(
3243 &self,
3244 paths: Vec<project::ProjectPath>,
3245 added_worktrees: Vec<Entity<project::Worktree>>,
3246 window: &mut Window,
3247 cx: &mut Context<Self>,
3248 ) {
3249 self.message_editor.update(cx, |message_editor, cx| {
3250 message_editor.insert_dragged_files(paths, window, cx);
3251 drop(added_worktrees);
3252 })
3253 }
3254
3255 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<'_, Self>) -> Option<Div> {
3256 let content = match self.thread_error.as_ref()? {
3257 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
3258 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
3259 ThreadError::ModelRequestLimitReached(plan) => {
3260 self.render_model_request_limit_reached_error(*plan, cx)
3261 }
3262 ThreadError::ToolUseLimitReached => {
3263 self.render_tool_use_limit_reached_error(window, cx)?
3264 }
3265 };
3266
3267 Some(div().child(content))
3268 }
3269
3270 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
3271 Callout::new()
3272 .severity(Severity::Error)
3273 .title("Error")
3274 .description(error.clone())
3275 .actions_slot(self.create_copy_button(error.to_string()))
3276 .dismiss_action(self.dismiss_error_button(cx))
3277 }
3278
3279 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
3280 const ERROR_MESSAGE: &str =
3281 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
3282
3283 Callout::new()
3284 .severity(Severity::Error)
3285 .title("Free Usage Exceeded")
3286 .description(ERROR_MESSAGE)
3287 .actions_slot(
3288 h_flex()
3289 .gap_0p5()
3290 .child(self.upgrade_button(cx))
3291 .child(self.create_copy_button(ERROR_MESSAGE)),
3292 )
3293 .dismiss_action(self.dismiss_error_button(cx))
3294 }
3295
3296 fn render_model_request_limit_reached_error(
3297 &self,
3298 plan: cloud_llm_client::Plan,
3299 cx: &mut Context<Self>,
3300 ) -> Callout {
3301 let error_message = match plan {
3302 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
3303 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
3304 "Upgrade to Zed Pro for more prompts."
3305 }
3306 };
3307
3308 Callout::new()
3309 .severity(Severity::Error)
3310 .title("Model Prompt Limit Reached")
3311 .description(error_message)
3312 .actions_slot(
3313 h_flex()
3314 .gap_0p5()
3315 .child(self.upgrade_button(cx))
3316 .child(self.create_copy_button(error_message)),
3317 )
3318 .dismiss_action(self.dismiss_error_button(cx))
3319 }
3320
3321 fn render_tool_use_limit_reached_error(
3322 &self,
3323 window: &mut Window,
3324 cx: &mut Context<Self>,
3325 ) -> Option<Callout> {
3326 let thread = self.as_native_thread(cx)?;
3327 let supports_burn_mode = thread
3328 .read(cx)
3329 .model()
3330 .map_or(false, |model| model.supports_burn_mode());
3331
3332 let focus_handle = self.focus_handle(cx);
3333
3334 Some(
3335 Callout::new()
3336 .icon(IconName::Info)
3337 .title("Consecutive tool use limit reached.")
3338 .actions_slot(
3339 h_flex()
3340 .gap_0p5()
3341 .when(supports_burn_mode, |this| {
3342 this.child(
3343 Button::new("continue-burn-mode", "Continue with Burn Mode")
3344 .style(ButtonStyle::Filled)
3345 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3346 .layer(ElevationIndex::ModalSurface)
3347 .label_size(LabelSize::Small)
3348 .key_binding(
3349 KeyBinding::for_action_in(
3350 &ContinueWithBurnMode,
3351 &focus_handle,
3352 window,
3353 cx,
3354 )
3355 .map(|kb| kb.size(rems_from_px(10.))),
3356 )
3357 .tooltip(Tooltip::text(
3358 "Enable Burn Mode for unlimited tool use.",
3359 ))
3360 .on_click({
3361 cx.listener(move |this, _, _window, cx| {
3362 thread.update(cx, |thread, _cx| {
3363 thread.set_completion_mode(CompletionMode::Burn);
3364 });
3365 this.resume_chat(cx);
3366 })
3367 }),
3368 )
3369 })
3370 .child(
3371 Button::new("continue-conversation", "Continue")
3372 .layer(ElevationIndex::ModalSurface)
3373 .label_size(LabelSize::Small)
3374 .key_binding(
3375 KeyBinding::for_action_in(
3376 &ContinueThread,
3377 &focus_handle,
3378 window,
3379 cx,
3380 )
3381 .map(|kb| kb.size(rems_from_px(10.))),
3382 )
3383 .on_click(cx.listener(|this, _, _window, cx| {
3384 this.resume_chat(cx);
3385 })),
3386 ),
3387 ),
3388 )
3389 }
3390
3391 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
3392 let message = message.into();
3393
3394 IconButton::new("copy", IconName::Copy)
3395 .icon_size(IconSize::Small)
3396 .icon_color(Color::Muted)
3397 .tooltip(Tooltip::text("Copy Error Message"))
3398 .on_click(move |_, _, cx| {
3399 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
3400 })
3401 }
3402
3403 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3404 IconButton::new("dismiss", IconName::Close)
3405 .icon_size(IconSize::Small)
3406 .icon_color(Color::Muted)
3407 .tooltip(Tooltip::text("Dismiss Error"))
3408 .on_click(cx.listener({
3409 move |this, _, _, cx| {
3410 this.clear_thread_error(cx);
3411 cx.notify();
3412 }
3413 }))
3414 }
3415
3416 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3417 Button::new("upgrade", "Upgrade")
3418 .label_size(LabelSize::Small)
3419 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3420 .on_click(cx.listener({
3421 move |this, _, _, cx| {
3422 this.clear_thread_error(cx);
3423 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
3424 }
3425 }))
3426 }
3427}
3428
3429impl Focusable for AcpThreadView {
3430 fn focus_handle(&self, cx: &App) -> FocusHandle {
3431 self.message_editor.focus_handle(cx)
3432 }
3433}
3434
3435impl Render for AcpThreadView {
3436 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3437 let has_messages = self.list_state.item_count() > 0;
3438
3439 v_flex()
3440 .size_full()
3441 .key_context("AcpThread")
3442 .on_action(cx.listener(Self::open_agent_diff))
3443 .on_action(cx.listener(Self::toggle_burn_mode))
3444 .bg(cx.theme().colors().panel_background)
3445 .child(match &self.thread_state {
3446 ThreadState::Unauthenticated {
3447 connection,
3448 description,
3449 configuration_view,
3450 ..
3451 } => self.render_auth_required_state(
3452 connection,
3453 description.as_ref(),
3454 configuration_view.as_ref(),
3455 window,
3456 cx,
3457 ),
3458 ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
3459 ThreadState::LoadError(e) => v_flex()
3460 .p_2()
3461 .flex_1()
3462 .items_center()
3463 .justify_center()
3464 .child(self.render_load_error(e, cx)),
3465 ThreadState::ServerExited { status } => v_flex()
3466 .p_2()
3467 .flex_1()
3468 .items_center()
3469 .justify_center()
3470 .child(self.render_server_exited(*status, cx)),
3471 ThreadState::Ready { thread, .. } => {
3472 let thread_clone = thread.clone();
3473
3474 v_flex().flex_1().map(|this| {
3475 if has_messages {
3476 this.child(
3477 list(
3478 self.list_state.clone(),
3479 cx.processor(|this, index: usize, window, cx| {
3480 let Some((entry, len)) = this.thread().and_then(|thread| {
3481 let entries = &thread.read(cx).entries();
3482 Some((entries.get(index)?, entries.len()))
3483 }) else {
3484 return Empty.into_any();
3485 };
3486 this.render_entry(index, len, entry, window, cx)
3487 }),
3488 )
3489 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
3490 .flex_grow()
3491 .into_any(),
3492 )
3493 .child(self.render_vertical_scrollbar(cx))
3494 .children(
3495 match thread_clone.read(cx).status() {
3496 ThreadStatus::Idle
3497 | ThreadStatus::WaitingForToolConfirmation => None,
3498 ThreadStatus::Generating => div()
3499 .px_5()
3500 .py_2()
3501 .child(LoadingLabel::new("").size(LabelSize::Small))
3502 .into(),
3503 },
3504 )
3505 } else {
3506 this.child(self.render_empty_state(cx))
3507 }
3508 })
3509 }
3510 })
3511 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
3512 // above so that the scrollbar doesn't render behind it. The current setup allows
3513 // the scrollbar to stop exactly at the activity bar start.
3514 .when(has_messages, |this| match &self.thread_state {
3515 ThreadState::Ready { thread, .. } => {
3516 this.children(self.render_activity_bar(thread, window, cx))
3517 }
3518 _ => this,
3519 })
3520 .children(self.render_thread_error(window, cx))
3521 .child(self.render_message_editor(window, cx))
3522 }
3523}
3524
3525fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
3526 let theme_settings = ThemeSettings::get_global(cx);
3527 let colors = cx.theme().colors();
3528
3529 let buffer_font_size = TextSize::Small.rems(cx);
3530
3531 let mut text_style = window.text_style();
3532 let line_height = buffer_font_size * 1.75;
3533
3534 let font_family = if buffer_font {
3535 theme_settings.buffer_font.family.clone()
3536 } else {
3537 theme_settings.ui_font.family.clone()
3538 };
3539
3540 let font_size = if buffer_font {
3541 TextSize::Small.rems(cx)
3542 } else {
3543 TextSize::Default.rems(cx)
3544 };
3545
3546 text_style.refine(&TextStyleRefinement {
3547 font_family: Some(font_family),
3548 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
3549 font_features: Some(theme_settings.ui_font.features.clone()),
3550 font_size: Some(font_size.into()),
3551 line_height: Some(line_height.into()),
3552 color: Some(cx.theme().colors().text),
3553 ..Default::default()
3554 });
3555
3556 MarkdownStyle {
3557 base_text_style: text_style.clone(),
3558 syntax: cx.theme().syntax().clone(),
3559 selection_background_color: cx.theme().colors().element_selection_background,
3560 code_block_overflow_x_scroll: true,
3561 table_overflow_x_scroll: true,
3562 heading_level_styles: Some(HeadingLevelStyles {
3563 h1: Some(TextStyleRefinement {
3564 font_size: Some(rems(1.15).into()),
3565 ..Default::default()
3566 }),
3567 h2: Some(TextStyleRefinement {
3568 font_size: Some(rems(1.1).into()),
3569 ..Default::default()
3570 }),
3571 h3: Some(TextStyleRefinement {
3572 font_size: Some(rems(1.05).into()),
3573 ..Default::default()
3574 }),
3575 h4: Some(TextStyleRefinement {
3576 font_size: Some(rems(1.).into()),
3577 ..Default::default()
3578 }),
3579 h5: Some(TextStyleRefinement {
3580 font_size: Some(rems(0.95).into()),
3581 ..Default::default()
3582 }),
3583 h6: Some(TextStyleRefinement {
3584 font_size: Some(rems(0.875).into()),
3585 ..Default::default()
3586 }),
3587 }),
3588 code_block: StyleRefinement {
3589 padding: EdgesRefinement {
3590 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3591 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3592 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3593 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3594 },
3595 margin: EdgesRefinement {
3596 top: Some(Length::Definite(Pixels(8.).into())),
3597 left: Some(Length::Definite(Pixels(0.).into())),
3598 right: Some(Length::Definite(Pixels(0.).into())),
3599 bottom: Some(Length::Definite(Pixels(12.).into())),
3600 },
3601 border_style: Some(BorderStyle::Solid),
3602 border_widths: EdgesRefinement {
3603 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
3604 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
3605 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
3606 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
3607 },
3608 border_color: Some(colors.border_variant),
3609 background: Some(colors.editor_background.into()),
3610 text: Some(TextStyleRefinement {
3611 font_family: Some(theme_settings.buffer_font.family.clone()),
3612 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3613 font_features: Some(theme_settings.buffer_font.features.clone()),
3614 font_size: Some(buffer_font_size.into()),
3615 ..Default::default()
3616 }),
3617 ..Default::default()
3618 },
3619 inline_code: TextStyleRefinement {
3620 font_family: Some(theme_settings.buffer_font.family.clone()),
3621 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3622 font_features: Some(theme_settings.buffer_font.features.clone()),
3623 font_size: Some(buffer_font_size.into()),
3624 background_color: Some(colors.editor_foreground.opacity(0.08)),
3625 ..Default::default()
3626 },
3627 link: TextStyleRefinement {
3628 background_color: Some(colors.editor_foreground.opacity(0.025)),
3629 underline: Some(UnderlineStyle {
3630 color: Some(colors.text_accent.opacity(0.5)),
3631 thickness: px(1.),
3632 ..Default::default()
3633 }),
3634 ..Default::default()
3635 },
3636 ..Default::default()
3637 }
3638}
3639
3640fn plan_label_markdown_style(
3641 status: &acp::PlanEntryStatus,
3642 window: &Window,
3643 cx: &App,
3644) -> MarkdownStyle {
3645 let default_md_style = default_markdown_style(false, window, cx);
3646
3647 MarkdownStyle {
3648 base_text_style: TextStyle {
3649 color: cx.theme().colors().text_muted,
3650 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
3651 Some(gpui::StrikethroughStyle {
3652 thickness: px(1.),
3653 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
3654 })
3655 } else {
3656 None
3657 },
3658 ..default_md_style.base_text_style
3659 },
3660 ..default_md_style
3661 }
3662}
3663
3664fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
3665 let default_md_style = default_markdown_style(true, window, cx);
3666
3667 MarkdownStyle {
3668 base_text_style: TextStyle {
3669 ..default_md_style.base_text_style
3670 },
3671 selection_background_color: cx.theme().colors().element_selection_background,
3672 ..Default::default()
3673 }
3674}
3675
3676#[cfg(test)]
3677pub(crate) mod tests {
3678 use acp_thread::StubAgentConnection;
3679 use agent::{TextThreadStore, ThreadStore};
3680 use agent_client_protocol::SessionId;
3681 use editor::EditorSettings;
3682 use fs::FakeFs;
3683 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
3684 use project::Project;
3685 use serde_json::json;
3686 use settings::SettingsStore;
3687 use std::any::Any;
3688 use std::path::Path;
3689 use workspace::Item;
3690
3691 use super::*;
3692
3693 #[gpui::test]
3694 async fn test_drop(cx: &mut TestAppContext) {
3695 init_test(cx);
3696
3697 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3698 let weak_view = thread_view.downgrade();
3699 drop(thread_view);
3700 assert!(!weak_view.is_upgradable());
3701 }
3702
3703 #[gpui::test]
3704 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3705 init_test(cx);
3706
3707 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3708
3709 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3710 message_editor.update_in(cx, |editor, window, cx| {
3711 editor.set_text("Hello", window, cx);
3712 });
3713
3714 cx.deactivate_window();
3715
3716 thread_view.update_in(cx, |thread_view, window, cx| {
3717 thread_view.send(window, cx);
3718 });
3719
3720 cx.run_until_parked();
3721
3722 assert!(
3723 cx.windows()
3724 .iter()
3725 .any(|window| window.downcast::<AgentNotification>().is_some())
3726 );
3727 }
3728
3729 #[gpui::test]
3730 async fn test_notification_for_error(cx: &mut TestAppContext) {
3731 init_test(cx);
3732
3733 let (thread_view, cx) =
3734 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
3735
3736 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3737 message_editor.update_in(cx, |editor, window, cx| {
3738 editor.set_text("Hello", window, cx);
3739 });
3740
3741 cx.deactivate_window();
3742
3743 thread_view.update_in(cx, |thread_view, window, cx| {
3744 thread_view.send(window, cx);
3745 });
3746
3747 cx.run_until_parked();
3748
3749 assert!(
3750 cx.windows()
3751 .iter()
3752 .any(|window| window.downcast::<AgentNotification>().is_some())
3753 );
3754 }
3755
3756 #[gpui::test]
3757 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3758 init_test(cx);
3759
3760 let tool_call_id = acp::ToolCallId("1".into());
3761 let tool_call = acp::ToolCall {
3762 id: tool_call_id.clone(),
3763 title: "Label".into(),
3764 kind: acp::ToolKind::Edit,
3765 status: acp::ToolCallStatus::Pending,
3766 content: vec!["hi".into()],
3767 locations: vec![],
3768 raw_input: None,
3769 raw_output: None,
3770 };
3771 let connection =
3772 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3773 tool_call_id,
3774 vec![acp::PermissionOption {
3775 id: acp::PermissionOptionId("1".into()),
3776 name: "Allow".into(),
3777 kind: acp::PermissionOptionKind::AllowOnce,
3778 }],
3779 )]));
3780
3781 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3782
3783 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3784
3785 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3786 message_editor.update_in(cx, |editor, window, cx| {
3787 editor.set_text("Hello", window, cx);
3788 });
3789
3790 cx.deactivate_window();
3791
3792 thread_view.update_in(cx, |thread_view, window, cx| {
3793 thread_view.send(window, cx);
3794 });
3795
3796 cx.run_until_parked();
3797
3798 assert!(
3799 cx.windows()
3800 .iter()
3801 .any(|window| window.downcast::<AgentNotification>().is_some())
3802 );
3803 }
3804
3805 async fn setup_thread_view(
3806 agent: impl AgentServer + 'static,
3807 cx: &mut TestAppContext,
3808 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
3809 let fs = FakeFs::new(cx.executor());
3810 let project = Project::test(fs, [], cx).await;
3811 let (workspace, cx) =
3812 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3813
3814 let thread_store =
3815 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
3816 let text_thread_store =
3817 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
3818
3819 let thread_view = cx.update(|window, cx| {
3820 cx.new(|cx| {
3821 AcpThreadView::new(
3822 Rc::new(agent),
3823 workspace.downgrade(),
3824 project,
3825 thread_store.clone(),
3826 text_thread_store.clone(),
3827 window,
3828 cx,
3829 )
3830 })
3831 });
3832 cx.run_until_parked();
3833 (thread_view, cx)
3834 }
3835
3836 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
3837 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3838
3839 workspace
3840 .update_in(cx, |workspace, window, cx| {
3841 workspace.add_item_to_active_pane(
3842 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
3843 None,
3844 true,
3845 window,
3846 cx,
3847 );
3848 })
3849 .unwrap();
3850 }
3851
3852 struct ThreadViewItem(Entity<AcpThreadView>);
3853
3854 impl Item for ThreadViewItem {
3855 type Event = ();
3856
3857 fn include_in_nav_history() -> bool {
3858 false
3859 }
3860
3861 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3862 "Test".into()
3863 }
3864 }
3865
3866 impl EventEmitter<()> for ThreadViewItem {}
3867
3868 impl Focusable for ThreadViewItem {
3869 fn focus_handle(&self, cx: &App) -> FocusHandle {
3870 self.0.read(cx).focus_handle(cx).clone()
3871 }
3872 }
3873
3874 impl Render for ThreadViewItem {
3875 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3876 self.0.clone().into_any_element()
3877 }
3878 }
3879
3880 struct StubAgentServer<C> {
3881 connection: C,
3882 }
3883
3884 impl<C> StubAgentServer<C> {
3885 fn new(connection: C) -> Self {
3886 Self { connection }
3887 }
3888 }
3889
3890 impl StubAgentServer<StubAgentConnection> {
3891 fn default_response() -> Self {
3892 let conn = StubAgentConnection::new();
3893 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
3894 content: "Default response".into(),
3895 }]);
3896 Self::new(conn)
3897 }
3898 }
3899
3900 impl<C> AgentServer for StubAgentServer<C>
3901 where
3902 C: 'static + AgentConnection + Send + Clone,
3903 {
3904 fn logo(&self) -> ui::IconName {
3905 ui::IconName::Ai
3906 }
3907
3908 fn name(&self) -> &'static str {
3909 "Test"
3910 }
3911
3912 fn empty_state_headline(&self) -> &'static str {
3913 "Test"
3914 }
3915
3916 fn empty_state_message(&self) -> &'static str {
3917 "Test"
3918 }
3919
3920 fn connect(
3921 &self,
3922 _root_dir: &Path,
3923 _project: &Entity<Project>,
3924 _cx: &mut App,
3925 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3926 Task::ready(Ok(Rc::new(self.connection.clone())))
3927 }
3928 }
3929
3930 #[derive(Clone)]
3931 struct SaboteurAgentConnection;
3932
3933 impl AgentConnection for SaboteurAgentConnection {
3934 fn new_thread(
3935 self: Rc<Self>,
3936 project: Entity<Project>,
3937 _cwd: &Path,
3938 cx: &mut gpui::App,
3939 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3940 Task::ready(Ok(cx.new(|cx| {
3941 AcpThread::new(
3942 "SaboteurAgentConnection",
3943 self,
3944 project,
3945 SessionId("test".into()),
3946 cx,
3947 )
3948 })))
3949 }
3950
3951 fn auth_methods(&self) -> &[acp::AuthMethod] {
3952 &[]
3953 }
3954
3955 fn authenticate(
3956 &self,
3957 _method_id: acp::AuthMethodId,
3958 _cx: &mut App,
3959 ) -> Task<gpui::Result<()>> {
3960 unimplemented!()
3961 }
3962
3963 fn prompt(
3964 &self,
3965 _id: Option<acp_thread::UserMessageId>,
3966 _params: acp::PromptRequest,
3967 _cx: &mut App,
3968 ) -> Task<gpui::Result<acp::PromptResponse>> {
3969 Task::ready(Err(anyhow::anyhow!("Error prompting")))
3970 }
3971
3972 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3973 unimplemented!()
3974 }
3975
3976 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3977 self
3978 }
3979 }
3980
3981 pub(crate) fn init_test(cx: &mut TestAppContext) {
3982 cx.update(|cx| {
3983 let settings_store = SettingsStore::test(cx);
3984 cx.set_global(settings_store);
3985 language::init(cx);
3986 Project::init_settings(cx);
3987 AgentSettings::register(cx);
3988 workspace::init_settings(cx);
3989 ThemeSettings::register(cx);
3990 release_channel::init(SemanticVersion::default(), cx);
3991 EditorSettings::register(cx);
3992 });
3993 }
3994
3995 #[gpui::test]
3996 async fn test_rewind_views(cx: &mut TestAppContext) {
3997 init_test(cx);
3998
3999 let fs = FakeFs::new(cx.executor());
4000 fs.insert_tree(
4001 "/project",
4002 json!({
4003 "test1.txt": "old content 1",
4004 "test2.txt": "old content 2"
4005 }),
4006 )
4007 .await;
4008 let project = Project::test(fs, [Path::new("/project")], cx).await;
4009 let (workspace, cx) =
4010 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
4011
4012 let thread_store =
4013 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
4014 let text_thread_store =
4015 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
4016
4017 let connection = Rc::new(StubAgentConnection::new());
4018 let thread_view = cx.update(|window, cx| {
4019 cx.new(|cx| {
4020 AcpThreadView::new(
4021 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4022 workspace.downgrade(),
4023 project.clone(),
4024 thread_store.clone(),
4025 text_thread_store.clone(),
4026 window,
4027 cx,
4028 )
4029 })
4030 });
4031
4032 cx.run_until_parked();
4033
4034 let thread = thread_view
4035 .read_with(cx, |view, _| view.thread().cloned())
4036 .unwrap();
4037
4038 // First user message
4039 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4040 id: acp::ToolCallId("tool1".into()),
4041 title: "Edit file 1".into(),
4042 kind: acp::ToolKind::Edit,
4043 status: acp::ToolCallStatus::Completed,
4044 content: vec![acp::ToolCallContent::Diff {
4045 diff: acp::Diff {
4046 path: "/project/test1.txt".into(),
4047 old_text: Some("old content 1".into()),
4048 new_text: "new content 1".into(),
4049 },
4050 }],
4051 locations: vec![],
4052 raw_input: None,
4053 raw_output: None,
4054 })]);
4055
4056 thread
4057 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4058 .await
4059 .unwrap();
4060 cx.run_until_parked();
4061
4062 thread.read_with(cx, |thread, _| {
4063 assert_eq!(thread.entries().len(), 2);
4064 });
4065
4066 thread_view.read_with(cx, |view, cx| {
4067 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4068 assert!(
4069 entry_view_state
4070 .entry(0)
4071 .unwrap()
4072 .message_editor()
4073 .is_some()
4074 );
4075 assert!(entry_view_state.entry(1).unwrap().has_content());
4076 });
4077 });
4078
4079 // Second user message
4080 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4081 id: acp::ToolCallId("tool2".into()),
4082 title: "Edit file 2".into(),
4083 kind: acp::ToolKind::Edit,
4084 status: acp::ToolCallStatus::Completed,
4085 content: vec![acp::ToolCallContent::Diff {
4086 diff: acp::Diff {
4087 path: "/project/test2.txt".into(),
4088 old_text: Some("old content 2".into()),
4089 new_text: "new content 2".into(),
4090 },
4091 }],
4092 locations: vec![],
4093 raw_input: None,
4094 raw_output: None,
4095 })]);
4096
4097 thread
4098 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4099 .await
4100 .unwrap();
4101 cx.run_until_parked();
4102
4103 let second_user_message_id = thread.read_with(cx, |thread, _| {
4104 assert_eq!(thread.entries().len(), 4);
4105 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4106 panic!();
4107 };
4108 user_message.id.clone().unwrap()
4109 });
4110
4111 thread_view.read_with(cx, |view, cx| {
4112 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4113 assert!(
4114 entry_view_state
4115 .entry(0)
4116 .unwrap()
4117 .message_editor()
4118 .is_some()
4119 );
4120 assert!(entry_view_state.entry(1).unwrap().has_content());
4121 assert!(
4122 entry_view_state
4123 .entry(2)
4124 .unwrap()
4125 .message_editor()
4126 .is_some()
4127 );
4128 assert!(entry_view_state.entry(3).unwrap().has_content());
4129 });
4130 });
4131
4132 // Rewind to first message
4133 thread
4134 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4135 .await
4136 .unwrap();
4137
4138 cx.run_until_parked();
4139
4140 thread.read_with(cx, |thread, _| {
4141 assert_eq!(thread.entries().len(), 2);
4142 });
4143
4144 thread_view.read_with(cx, |view, cx| {
4145 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4146 assert!(
4147 entry_view_state
4148 .entry(0)
4149 .unwrap()
4150 .message_editor()
4151 .is_some()
4152 );
4153 assert!(entry_view_state.entry(1).unwrap().has_content());
4154
4155 // Old views should be dropped
4156 assert!(entry_view_state.entry(2).is_none());
4157 assert!(entry_view_state.entry(3).is_none());
4158 });
4159 });
4160 }
4161
4162 #[gpui::test]
4163 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4164 init_test(cx);
4165
4166 let connection = StubAgentConnection::new();
4167
4168 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4169 content: acp::ContentBlock::Text(acp::TextContent {
4170 text: "Response".into(),
4171 annotations: None,
4172 }),
4173 }]);
4174
4175 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4176 add_to_workspace(thread_view.clone(), cx);
4177
4178 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4179 message_editor.update_in(cx, |editor, window, cx| {
4180 editor.set_text("Original message to edit", window, cx);
4181 });
4182 thread_view.update_in(cx, |thread_view, window, cx| {
4183 thread_view.send(window, cx);
4184 });
4185
4186 cx.run_until_parked();
4187
4188 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4189 assert_eq!(view.editing_message, None);
4190
4191 view.entry_view_state
4192 .read(cx)
4193 .entry(0)
4194 .unwrap()
4195 .message_editor()
4196 .unwrap()
4197 .clone()
4198 });
4199
4200 // Focus
4201 cx.focus(&user_message_editor);
4202 thread_view.read_with(cx, |view, _cx| {
4203 assert_eq!(view.editing_message, Some(0));
4204 });
4205
4206 // Edit
4207 user_message_editor.update_in(cx, |editor, window, cx| {
4208 editor.set_text("Edited message content", window, cx);
4209 });
4210
4211 // Cancel
4212 user_message_editor.update_in(cx, |_editor, window, cx| {
4213 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4214 });
4215
4216 thread_view.read_with(cx, |view, _cx| {
4217 assert_eq!(view.editing_message, None);
4218 });
4219
4220 user_message_editor.read_with(cx, |editor, cx| {
4221 assert_eq!(editor.text(cx), "Original message to edit");
4222 });
4223 }
4224
4225 #[gpui::test]
4226 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4227 init_test(cx);
4228
4229 let connection = StubAgentConnection::new();
4230
4231 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4232 content: acp::ContentBlock::Text(acp::TextContent {
4233 text: "Response".into(),
4234 annotations: None,
4235 }),
4236 }]);
4237
4238 let (thread_view, cx) =
4239 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4240 add_to_workspace(thread_view.clone(), cx);
4241
4242 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4243 message_editor.update_in(cx, |editor, window, cx| {
4244 editor.set_text("Original message to edit", window, cx);
4245 });
4246 thread_view.update_in(cx, |thread_view, window, cx| {
4247 thread_view.send(window, cx);
4248 });
4249
4250 cx.run_until_parked();
4251
4252 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4253 assert_eq!(view.editing_message, None);
4254 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
4255
4256 view.entry_view_state
4257 .read(cx)
4258 .entry(0)
4259 .unwrap()
4260 .message_editor()
4261 .unwrap()
4262 .clone()
4263 });
4264
4265 // Focus
4266 cx.focus(&user_message_editor);
4267
4268 // Edit
4269 user_message_editor.update_in(cx, |editor, window, cx| {
4270 editor.set_text("Edited message content", window, cx);
4271 });
4272
4273 // Send
4274 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4275 content: acp::ContentBlock::Text(acp::TextContent {
4276 text: "New Response".into(),
4277 annotations: None,
4278 }),
4279 }]);
4280
4281 user_message_editor.update_in(cx, |_editor, window, cx| {
4282 window.dispatch_action(Box::new(Chat), cx);
4283 });
4284
4285 cx.run_until_parked();
4286
4287 thread_view.read_with(cx, |view, cx| {
4288 assert_eq!(view.editing_message, None);
4289
4290 let entries = view.thread().unwrap().read(cx).entries();
4291 assert_eq!(entries.len(), 2);
4292 assert_eq!(
4293 entries[0].to_markdown(cx),
4294 "## User\n\nEdited message content\n\n"
4295 );
4296 assert_eq!(
4297 entries[1].to_markdown(cx),
4298 "## Assistant\n\nNew Response\n\n"
4299 );
4300
4301 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
4302 assert!(!state.entry(1).unwrap().has_content());
4303 state.entry(0).unwrap().message_editor().unwrap().clone()
4304 });
4305
4306 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4307 })
4308 }
4309
4310 #[gpui::test]
4311 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4312 init_test(cx);
4313
4314 let connection = StubAgentConnection::new();
4315
4316 let (thread_view, cx) =
4317 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4318 add_to_workspace(thread_view.clone(), cx);
4319
4320 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4321 message_editor.update_in(cx, |editor, window, cx| {
4322 editor.set_text("Original message to edit", window, cx);
4323 });
4324 thread_view.update_in(cx, |thread_view, window, cx| {
4325 thread_view.send(window, cx);
4326 });
4327
4328 cx.run_until_parked();
4329
4330 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
4331 let thread = view.thread().unwrap().read(cx);
4332 assert_eq!(thread.entries().len(), 1);
4333
4334 let editor = view
4335 .entry_view_state
4336 .read(cx)
4337 .entry(0)
4338 .unwrap()
4339 .message_editor()
4340 .unwrap()
4341 .clone();
4342
4343 (editor, thread.session_id().clone())
4344 });
4345
4346 // Focus
4347 cx.focus(&user_message_editor);
4348
4349 thread_view.read_with(cx, |view, _cx| {
4350 assert_eq!(view.editing_message, Some(0));
4351 });
4352
4353 // Edit
4354 user_message_editor.update_in(cx, |editor, window, cx| {
4355 editor.set_text("Edited message content", window, cx);
4356 });
4357
4358 thread_view.read_with(cx, |view, _cx| {
4359 assert_eq!(view.editing_message, Some(0));
4360 });
4361
4362 // Finish streaming response
4363 cx.update(|_, cx| {
4364 connection.send_update(
4365 session_id.clone(),
4366 acp::SessionUpdate::AgentMessageChunk {
4367 content: acp::ContentBlock::Text(acp::TextContent {
4368 text: "Response".into(),
4369 annotations: None,
4370 }),
4371 },
4372 cx,
4373 );
4374 connection.end_turn(session_id, acp::StopReason::EndTurn);
4375 });
4376
4377 thread_view.read_with(cx, |view, _cx| {
4378 assert_eq!(view.editing_message, Some(0));
4379 });
4380
4381 cx.run_until_parked();
4382
4383 // Should still be editing
4384 cx.update(|window, cx| {
4385 assert!(user_message_editor.focus_handle(cx).is_focused(window));
4386 assert_eq!(thread_view.read(cx).editing_message, Some(0));
4387 assert_eq!(
4388 user_message_editor.read(cx).text(cx),
4389 "Edited message content"
4390 );
4391 });
4392 }
4393
4394 #[gpui::test]
4395 async fn test_interrupt(cx: &mut TestAppContext) {
4396 init_test(cx);
4397
4398 let connection = StubAgentConnection::new();
4399
4400 let (thread_view, cx) =
4401 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4402 add_to_workspace(thread_view.clone(), cx);
4403
4404 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4405 message_editor.update_in(cx, |editor, window, cx| {
4406 editor.set_text("Message 1", window, cx);
4407 });
4408 thread_view.update_in(cx, |thread_view, window, cx| {
4409 thread_view.send(window, cx);
4410 });
4411
4412 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4413 let thread = view.thread().unwrap();
4414
4415 (thread.clone(), thread.read(cx).session_id().clone())
4416 });
4417
4418 cx.run_until_parked();
4419
4420 cx.update(|_, cx| {
4421 connection.send_update(
4422 session_id.clone(),
4423 acp::SessionUpdate::AgentMessageChunk {
4424 content: "Message 1 resp".into(),
4425 },
4426 cx,
4427 );
4428 });
4429
4430 cx.run_until_parked();
4431
4432 thread.read_with(cx, |thread, cx| {
4433 assert_eq!(
4434 thread.to_markdown(cx),
4435 indoc::indoc! {"
4436 ## User
4437
4438 Message 1
4439
4440 ## Assistant
4441
4442 Message 1 resp
4443
4444 "}
4445 )
4446 });
4447
4448 message_editor.update_in(cx, |editor, window, cx| {
4449 editor.set_text("Message 2", window, cx);
4450 });
4451 thread_view.update_in(cx, |thread_view, window, cx| {
4452 thread_view.send(window, cx);
4453 });
4454
4455 cx.update(|_, cx| {
4456 // Simulate a response sent after beginning to cancel
4457 connection.send_update(
4458 session_id.clone(),
4459 acp::SessionUpdate::AgentMessageChunk {
4460 content: "onse".into(),
4461 },
4462 cx,
4463 );
4464 });
4465
4466 cx.run_until_parked();
4467
4468 // Last Message 1 response should appear before Message 2
4469 thread.read_with(cx, |thread, cx| {
4470 assert_eq!(
4471 thread.to_markdown(cx),
4472 indoc::indoc! {"
4473 ## User
4474
4475 Message 1
4476
4477 ## Assistant
4478
4479 Message 1 response
4480
4481 ## User
4482
4483 Message 2
4484
4485 "}
4486 )
4487 });
4488
4489 cx.update(|_, cx| {
4490 connection.send_update(
4491 session_id.clone(),
4492 acp::SessionUpdate::AgentMessageChunk {
4493 content: "Message 2 response".into(),
4494 },
4495 cx,
4496 );
4497 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4498 });
4499
4500 cx.run_until_parked();
4501
4502 thread.read_with(cx, |thread, cx| {
4503 assert_eq!(
4504 thread.to_markdown(cx),
4505 indoc::indoc! {"
4506 ## User
4507
4508 Message 1
4509
4510 ## Assistant
4511
4512 Message 1 response
4513
4514 ## User
4515
4516 Message 2
4517
4518 ## Assistant
4519
4520 Message 2 response
4521
4522 "}
4523 )
4524 });
4525 }
4526}