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