1use acp_thread::{
2 AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
3 AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, ToolCall, ToolCallContent,
4 ToolCallStatus, UserMessageId,
5};
6use acp_thread::{AgentConnection, Plan};
7use action_log::ActionLog;
8use agent_client_protocol::{self as acp, PromptCapabilities};
9use agent_servers::{AgentServer, ClaudeCode};
10use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
11use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
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, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects};
19use file_icons::FileIcons;
20use fs::Fs;
21use gpui::{
22 Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
23 EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
24 ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription,
25 Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window,
26 WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point,
27 prelude::*, pulsating_between,
28};
29use language::Buffer;
30
31use language_model::LanguageModelRegistry;
32use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
33use project::{Project, ProjectEntryId};
34use prompt_store::{PromptId, PromptStore};
35use rope::Point;
36use settings::{Settings as _, SettingsStore};
37use std::cell::Cell;
38use std::path::Path;
39use std::sync::Arc;
40use std::time::Instant;
41use std::{collections::BTreeMap, rc::Rc, time::Duration};
42use text::Anchor;
43use theme::ThemeSettings;
44use ui::{
45 Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
46 Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
47};
48use util::{ResultExt, size::format_file_size, time::duration_alt_display};
49use workspace::{CollaboratorId, Workspace};
50use zed_actions::agent::{Chat, ToggleModelSelector};
51use zed_actions::assistant::OpenRulesLibrary;
52
53use super::entry_view_state::EntryViewState;
54use crate::acp::AcpModelSelectorPopover;
55use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
56use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
57use crate::agent_diff::AgentDiff;
58use crate::profile_selector::{ProfileProvider, ProfileSelector};
59
60use crate::ui::preview::UsageCallout;
61use crate::ui::{
62 AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
63};
64use crate::{
65 AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
66 KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
67};
68
69const RESPONSE_PADDING_X: Pixels = px(19.);
70pub const MIN_EDITOR_LINES: usize = 4;
71pub const MAX_EDITOR_LINES: usize = 8;
72
73#[derive(Copy, Clone, Debug, PartialEq, Eq)]
74enum ThreadFeedback {
75 Positive,
76 Negative,
77}
78
79enum ThreadError {
80 PaymentRequired,
81 ModelRequestLimitReached(cloud_llm_client::Plan),
82 ToolUseLimitReached,
83 AuthenticationRequired(SharedString),
84 Other(SharedString),
85}
86
87impl ThreadError {
88 fn from_err(error: anyhow::Error, agent: &Rc<dyn AgentServer>) -> Self {
89 if error.is::<language_model::PaymentRequiredError>() {
90 Self::PaymentRequired
91 } else if error.is::<language_model::ToolUseLimitReachedError>() {
92 Self::ToolUseLimitReached
93 } else if let Some(error) =
94 error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
95 {
96 Self::ModelRequestLimitReached(error.plan)
97 } else {
98 let string = error.to_string();
99 // TODO: we should have Gemini return better errors here.
100 if agent.clone().downcast::<agent_servers::Gemini>().is_some()
101 && string.contains("Could not load the default credentials")
102 || string.contains("API key not valid")
103 || string.contains("Request had invalid authentication credentials")
104 {
105 Self::AuthenticationRequired(string.into())
106 } else {
107 Self::Other(error.to_string().into())
108 }
109 }
110 }
111}
112
113impl ProfileProvider for Entity<agent2::Thread> {
114 fn profile_id(&self, cx: &App) -> AgentProfileId {
115 self.read(cx).profile().clone()
116 }
117
118 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
119 self.update(cx, |thread, _cx| {
120 thread.set_profile(profile_id);
121 });
122 }
123
124 fn profiles_supported(&self, cx: &App) -> bool {
125 self.read(cx)
126 .model()
127 .is_some_and(|model| model.supports_tools())
128 }
129}
130
131#[derive(Default)]
132struct ThreadFeedbackState {
133 feedback: Option<ThreadFeedback>,
134 comments_editor: Option<Entity<Editor>>,
135}
136
137impl ThreadFeedbackState {
138 pub fn submit(
139 &mut self,
140 thread: Entity<AcpThread>,
141 feedback: ThreadFeedback,
142 window: &mut Window,
143 cx: &mut App,
144 ) {
145 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
146 return;
147 };
148
149 if self.feedback == Some(feedback) {
150 return;
151 }
152
153 self.feedback = Some(feedback);
154 match feedback {
155 ThreadFeedback::Positive => {
156 self.comments_editor = None;
157 }
158 ThreadFeedback::Negative => {
159 self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
160 }
161 }
162 let session_id = thread.read(cx).session_id().clone();
163 let agent_name = telemetry.agent_name();
164 let task = telemetry.thread_data(&session_id, cx);
165 let rating = match feedback {
166 ThreadFeedback::Positive => "positive",
167 ThreadFeedback::Negative => "negative",
168 };
169 cx.background_spawn(async move {
170 let thread = task.await?;
171 telemetry::event!(
172 "Agent Thread Rated",
173 session_id = session_id,
174 rating = rating,
175 agent = agent_name,
176 thread = thread
177 );
178 anyhow::Ok(())
179 })
180 .detach_and_log_err(cx);
181 }
182
183 pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
184 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
185 return;
186 };
187
188 let Some(comments) = self
189 .comments_editor
190 .as_ref()
191 .map(|editor| editor.read(cx).text(cx))
192 .filter(|text| !text.trim().is_empty())
193 else {
194 return;
195 };
196
197 self.comments_editor.take();
198
199 let session_id = thread.read(cx).session_id().clone();
200 let agent_name = telemetry.agent_name();
201 let task = telemetry.thread_data(&session_id, cx);
202 cx.background_spawn(async move {
203 let thread = task.await?;
204 telemetry::event!(
205 "Agent Thread Feedback Comments",
206 session_id = session_id,
207 comments = comments,
208 agent = agent_name,
209 thread = thread
210 );
211 anyhow::Ok(())
212 })
213 .detach_and_log_err(cx);
214 }
215
216 pub fn clear(&mut self) {
217 *self = Self::default()
218 }
219
220 pub fn dismiss_comments(&mut self) {
221 self.comments_editor.take();
222 }
223
224 fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
225 let buffer = cx.new(|cx| {
226 let empty_string = String::new();
227 MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
228 });
229
230 let editor = cx.new(|cx| {
231 let mut editor = Editor::new(
232 editor::EditorMode::AutoHeight {
233 min_lines: 1,
234 max_lines: Some(4),
235 },
236 buffer,
237 None,
238 window,
239 cx,
240 );
241 editor.set_placeholder_text(
242 "What went wrong? Share your feedback so we can improve.",
243 cx,
244 );
245 editor
246 });
247
248 editor.read(cx).focus_handle(cx).focus(window);
249 editor
250 }
251}
252
253pub struct AcpThreadView {
254 agent: Rc<dyn AgentServer>,
255 workspace: WeakEntity<Workspace>,
256 project: Entity<Project>,
257 thread_state: ThreadState,
258 history_store: Entity<HistoryStore>,
259 hovered_recent_history_item: Option<usize>,
260 entry_view_state: Entity<EntryViewState>,
261 message_editor: Entity<MessageEditor>,
262 focus_handle: FocusHandle,
263 model_selector: Option<Entity<AcpModelSelectorPopover>>,
264 profile_selector: Option<Entity<ProfileSelector>>,
265 notifications: Vec<WindowHandle<AgentNotification>>,
266 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
267 thread_retry_status: Option<RetryStatus>,
268 thread_error: Option<ThreadError>,
269 thread_feedback: ThreadFeedbackState,
270 list_state: ListState,
271 scrollbar_state: ScrollbarState,
272 auth_task: Option<Task<()>>,
273 expanded_tool_calls: HashSet<acp::ToolCallId>,
274 expanded_thinking_blocks: HashSet<(usize, usize)>,
275 edits_expanded: bool,
276 plan_expanded: bool,
277 editor_expanded: bool,
278 should_be_following: bool,
279 editing_message: Option<usize>,
280 prompt_capabilities: Rc<Cell<PromptCapabilities>>,
281 is_loading_contents: bool,
282 _cancel_task: Option<Task<()>>,
283 _subscriptions: [Subscription; 3],
284}
285
286enum ThreadState {
287 Loading {
288 _task: Task<()>,
289 },
290 Ready {
291 thread: Entity<AcpThread>,
292 title_editor: Option<Entity<Editor>>,
293 _subscriptions: Vec<Subscription>,
294 },
295 LoadError(LoadError),
296 Unauthenticated {
297 connection: Rc<dyn AgentConnection>,
298 description: Option<Entity<Markdown>>,
299 configuration_view: Option<AnyView>,
300 pending_auth_method: Option<acp::AuthMethodId>,
301 _subscription: Option<Subscription>,
302 },
303}
304
305impl AcpThreadView {
306 pub fn new(
307 agent: Rc<dyn AgentServer>,
308 resume_thread: Option<DbThreadMetadata>,
309 summarize_thread: Option<DbThreadMetadata>,
310 workspace: WeakEntity<Workspace>,
311 project: Entity<Project>,
312 history_store: Entity<HistoryStore>,
313 prompt_store: Option<Entity<PromptStore>>,
314 window: &mut Window,
315 cx: &mut Context<Self>,
316 ) -> Self {
317 let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
318 let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
319
320 let placeholder = if agent.name() == "Zed Agent" {
321 format!("Message the {} — @ to include context", agent.name())
322 } else {
323 format!("Message {} — @ to include context", agent.name())
324 };
325
326 let message_editor = cx.new(|cx| {
327 let mut editor = MessageEditor::new(
328 workspace.clone(),
329 project.clone(),
330 history_store.clone(),
331 prompt_store.clone(),
332 prompt_capabilities.clone(),
333 placeholder,
334 prevent_slash_commands,
335 editor::EditorMode::AutoHeight {
336 min_lines: MIN_EDITOR_LINES,
337 max_lines: Some(MAX_EDITOR_LINES),
338 },
339 window,
340 cx,
341 );
342 if let Some(entry) = summarize_thread {
343 editor.insert_thread_summary(entry, window, cx);
344 }
345 editor
346 });
347
348 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
349
350 let entry_view_state = cx.new(|_| {
351 EntryViewState::new(
352 workspace.clone(),
353 project.clone(),
354 history_store.clone(),
355 prompt_store.clone(),
356 prompt_capabilities.clone(),
357 prevent_slash_commands,
358 )
359 });
360
361 let subscriptions = [
362 cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
363 cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
364 cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
365 ];
366
367 Self {
368 agent: agent.clone(),
369 workspace: workspace.clone(),
370 project: project.clone(),
371 entry_view_state,
372 thread_state: Self::initial_state(agent, resume_thread, workspace, project, window, cx),
373 message_editor,
374 model_selector: None,
375 profile_selector: None,
376 notifications: Vec::new(),
377 notification_subscriptions: HashMap::default(),
378 list_state: list_state.clone(),
379 scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
380 thread_retry_status: None,
381 thread_error: None,
382 thread_feedback: Default::default(),
383 auth_task: None,
384 expanded_tool_calls: HashSet::default(),
385 expanded_thinking_blocks: HashSet::default(),
386 editing_message: None,
387 edits_expanded: false,
388 plan_expanded: false,
389 editor_expanded: false,
390 should_be_following: false,
391 history_store,
392 hovered_recent_history_item: None,
393 prompt_capabilities,
394 is_loading_contents: false,
395 _subscriptions: subscriptions,
396 _cancel_task: None,
397 focus_handle: cx.focus_handle(),
398 }
399 }
400
401 fn initial_state(
402 agent: Rc<dyn AgentServer>,
403 resume_thread: Option<DbThreadMetadata>,
404 workspace: WeakEntity<Workspace>,
405 project: Entity<Project>,
406 window: &mut Window,
407 cx: &mut Context<Self>,
408 ) -> ThreadState {
409 let root_dir = project
410 .read(cx)
411 .visible_worktrees(cx)
412 .next()
413 .map(|worktree| worktree.read(cx).abs_path())
414 .unwrap_or_else(|| paths::home_dir().as_path().into());
415
416 let connect_task = agent.connect(&root_dir, &project, cx);
417 let load_task = cx.spawn_in(window, async move |this, cx| {
418 let connection = match connect_task.await {
419 Ok(connection) => connection,
420 Err(err) => {
421 this.update_in(cx, |this, window, cx| {
422 if err.downcast_ref::<LoadError>().is_some() {
423 this.handle_load_error(err, window, cx);
424 } else {
425 this.handle_thread_error(err, cx);
426 }
427 cx.notify();
428 })
429 .log_err();
430 return;
431 }
432 };
433
434 let result = if let Some(native_agent) = connection
435 .clone()
436 .downcast::<agent2::NativeAgentConnection>()
437 && let Some(resume) = resume_thread.clone()
438 {
439 cx.update(|_, cx| {
440 native_agent
441 .0
442 .update(cx, |agent, cx| agent.open_thread(resume.id, cx))
443 })
444 .log_err()
445 } else {
446 cx.update(|_, cx| {
447 connection
448 .clone()
449 .new_thread(project.clone(), &root_dir, cx)
450 })
451 .log_err()
452 };
453
454 let Some(result) = result else {
455 return;
456 };
457
458 let result = match result.await {
459 Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
460 Ok(err) => {
461 cx.update(|window, cx| {
462 Self::handle_auth_required(this, err, agent, connection, window, cx)
463 })
464 .log_err();
465 return;
466 }
467 Err(err) => Err(err),
468 },
469 Ok(thread) => Ok(thread),
470 };
471
472 this.update_in(cx, |this, window, cx| {
473 match result {
474 Ok(thread) => {
475 let action_log = thread.read(cx).action_log().clone();
476
477 this.prompt_capabilities
478 .set(thread.read(cx).prompt_capabilities());
479
480 let count = thread.read(cx).entries().len();
481 this.list_state.splice(0..0, count);
482 this.entry_view_state.update(cx, |view_state, cx| {
483 for ix in 0..count {
484 view_state.sync_entry(ix, &thread, window, cx);
485 }
486 });
487
488 if let Some(resume) = resume_thread {
489 this.history_store.update(cx, |history, cx| {
490 history.push_recently_opened_entry(
491 HistoryEntryId::AcpThread(resume.id),
492 cx,
493 );
494 });
495 }
496
497 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
498
499 this.model_selector =
500 thread
501 .read(cx)
502 .connection()
503 .model_selector()
504 .map(|selector| {
505 cx.new(|cx| {
506 AcpModelSelectorPopover::new(
507 thread.read(cx).session_id().clone(),
508 selector,
509 PopoverMenuHandle::default(),
510 this.focus_handle(cx),
511 window,
512 cx,
513 )
514 })
515 });
516
517 let mut subscriptions = vec![
518 cx.subscribe_in(&thread, window, Self::handle_thread_event),
519 cx.observe(&action_log, |_, _, cx| cx.notify()),
520 ];
521
522 let title_editor =
523 if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
524 let editor = cx.new(|cx| {
525 let mut editor = Editor::single_line(window, cx);
526 editor.set_text(thread.read(cx).title(), window, cx);
527 editor
528 });
529 subscriptions.push(cx.subscribe_in(
530 &editor,
531 window,
532 Self::handle_title_editor_event,
533 ));
534 Some(editor)
535 } else {
536 None
537 };
538 this.thread_state = ThreadState::Ready {
539 thread,
540 title_editor,
541 _subscriptions: subscriptions,
542 };
543 this.message_editor.focus_handle(cx).focus(window);
544
545 this.profile_selector = this.as_native_thread(cx).map(|thread| {
546 cx.new(|cx| {
547 ProfileSelector::new(
548 <dyn Fs>::global(cx),
549 Arc::new(thread.clone()),
550 this.focus_handle(cx),
551 cx,
552 )
553 })
554 });
555
556 cx.notify();
557 }
558 Err(err) => {
559 this.handle_load_error(err, window, cx);
560 }
561 };
562 })
563 .log_err();
564 });
565
566 ThreadState::Loading { _task: load_task }
567 }
568
569 fn handle_auth_required(
570 this: WeakEntity<Self>,
571 err: AuthRequired,
572 agent: Rc<dyn AgentServer>,
573 connection: Rc<dyn AgentConnection>,
574 window: &mut Window,
575 cx: &mut App,
576 ) {
577 let agent_name = agent.name();
578 let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
579 let registry = LanguageModelRegistry::global(cx);
580
581 let sub = window.subscribe(®istry, cx, {
582 let provider_id = provider_id.clone();
583 let this = this.clone();
584 move |_, ev, window, cx| {
585 if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
586 && &provider_id == updated_provider_id
587 {
588 this.update(cx, |this, cx| {
589 this.thread_state = Self::initial_state(
590 agent.clone(),
591 None,
592 this.workspace.clone(),
593 this.project.clone(),
594 window,
595 cx,
596 );
597 cx.notify();
598 })
599 .ok();
600 }
601 }
602 });
603
604 let view = registry.read(cx).provider(&provider_id).map(|provider| {
605 provider.configuration_view(
606 language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
607 window,
608 cx,
609 )
610 });
611
612 (view, Some(sub))
613 } else {
614 (None, None)
615 };
616
617 this.update(cx, |this, cx| {
618 this.thread_state = ThreadState::Unauthenticated {
619 pending_auth_method: None,
620 connection,
621 configuration_view,
622 description: err
623 .description
624 .clone()
625 .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
626 _subscription: subscription,
627 };
628 if this.message_editor.focus_handle(cx).is_focused(window) {
629 this.focus_handle.focus(window)
630 }
631 cx.notify();
632 })
633 .ok();
634 }
635
636 fn handle_load_error(
637 &mut self,
638 err: anyhow::Error,
639 window: &mut Window,
640 cx: &mut Context<Self>,
641 ) {
642 if let Some(load_err) = err.downcast_ref::<LoadError>() {
643 self.thread_state = ThreadState::LoadError(load_err.clone());
644 } else {
645 self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
646 }
647 if self.message_editor.focus_handle(cx).is_focused(window) {
648 self.focus_handle.focus(window)
649 }
650 cx.notify();
651 }
652
653 pub fn workspace(&self) -> &WeakEntity<Workspace> {
654 &self.workspace
655 }
656
657 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
658 match &self.thread_state {
659 ThreadState::Ready { thread, .. } => Some(thread),
660 ThreadState::Unauthenticated { .. }
661 | ThreadState::Loading { .. }
662 | ThreadState::LoadError { .. } => None,
663 }
664 }
665
666 pub fn title(&self) -> SharedString {
667 match &self.thread_state {
668 ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
669 ThreadState::Loading { .. } => "Loading…".into(),
670 ThreadState::LoadError(_) => "Failed to load".into(),
671 }
672 }
673
674 pub fn title_editor(&self) -> Option<Entity<Editor>> {
675 if let ThreadState::Ready { title_editor, .. } = &self.thread_state {
676 title_editor.clone()
677 } else {
678 None
679 }
680 }
681
682 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
683 self.thread_error.take();
684 self.thread_retry_status.take();
685
686 if let Some(thread) = self.thread() {
687 self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
688 }
689 }
690
691 pub fn expand_message_editor(
692 &mut self,
693 _: &ExpandMessageEditor,
694 _window: &mut Window,
695 cx: &mut Context<Self>,
696 ) {
697 self.set_editor_is_expanded(!self.editor_expanded, cx);
698 cx.notify();
699 }
700
701 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
702 self.editor_expanded = is_expanded;
703 self.message_editor.update(cx, |editor, cx| {
704 if is_expanded {
705 editor.set_mode(
706 EditorMode::Full {
707 scale_ui_elements_with_buffer_font_size: false,
708 show_active_line_background: false,
709 sized_by_content: false,
710 },
711 cx,
712 )
713 } else {
714 editor.set_mode(
715 EditorMode::AutoHeight {
716 min_lines: MIN_EDITOR_LINES,
717 max_lines: Some(MAX_EDITOR_LINES),
718 },
719 cx,
720 )
721 }
722 });
723 cx.notify();
724 }
725
726 pub fn handle_title_editor_event(
727 &mut self,
728 title_editor: &Entity<Editor>,
729 event: &EditorEvent,
730 window: &mut Window,
731 cx: &mut Context<Self>,
732 ) {
733 let Some(thread) = self.thread() else { return };
734
735 match event {
736 EditorEvent::BufferEdited => {
737 let new_title = title_editor.read(cx).text(cx);
738 thread.update(cx, |thread, cx| {
739 thread
740 .set_title(new_title.into(), cx)
741 .detach_and_log_err(cx);
742 })
743 }
744 EditorEvent::Blurred => {
745 if title_editor.read(cx).text(cx).is_empty() {
746 title_editor.update(cx, |editor, cx| {
747 editor.set_text("New Thread", window, cx);
748 });
749 }
750 }
751 _ => {}
752 }
753 }
754
755 pub fn handle_message_editor_event(
756 &mut self,
757 _: &Entity<MessageEditor>,
758 event: &MessageEditorEvent,
759 window: &mut Window,
760 cx: &mut Context<Self>,
761 ) {
762 match event {
763 MessageEditorEvent::Send => self.send(window, cx),
764 MessageEditorEvent::Cancel => self.cancel_generation(cx),
765 MessageEditorEvent::Focus => {
766 self.cancel_editing(&Default::default(), window, cx);
767 }
768 MessageEditorEvent::LostFocus => {}
769 }
770 }
771
772 pub fn handle_entry_view_event(
773 &mut self,
774 _: &Entity<EntryViewState>,
775 event: &EntryViewEvent,
776 window: &mut Window,
777 cx: &mut Context<Self>,
778 ) {
779 match &event.view_event {
780 ViewEvent::NewDiff(tool_call_id) => {
781 if AgentSettings::get_global(cx).expand_edit_card {
782 self.expanded_tool_calls.insert(tool_call_id.clone());
783 }
784 }
785 ViewEvent::NewTerminal(tool_call_id) => {
786 if AgentSettings::get_global(cx).expand_terminal_card {
787 self.expanded_tool_calls.insert(tool_call_id.clone());
788 }
789 }
790 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
791 if let Some(thread) = self.thread()
792 && let Some(AgentThreadEntry::UserMessage(user_message)) =
793 thread.read(cx).entries().get(event.entry_index)
794 && user_message.id.is_some()
795 {
796 self.editing_message = Some(event.entry_index);
797 cx.notify();
798 }
799 }
800 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
801 if let Some(thread) = self.thread()
802 && let Some(AgentThreadEntry::UserMessage(user_message)) =
803 thread.read(cx).entries().get(event.entry_index)
804 && user_message.id.is_some()
805 {
806 if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
807 self.editing_message = None;
808 cx.notify();
809 }
810 }
811 }
812 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
813 self.regenerate(event.entry_index, editor, window, cx);
814 }
815 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
816 self.cancel_editing(&Default::default(), window, cx);
817 }
818 }
819 }
820
821 fn resume_chat(&mut self, cx: &mut Context<Self>) {
822 self.thread_error.take();
823 let Some(thread) = self.thread() else {
824 return;
825 };
826 if !thread.read(cx).can_resume(cx) {
827 return;
828 }
829
830 let task = thread.update(cx, |thread, cx| thread.resume(cx));
831 cx.spawn(async move |this, cx| {
832 let result = task.await;
833
834 this.update(cx, |this, cx| {
835 if let Err(err) = result {
836 this.handle_thread_error(err, cx);
837 }
838 })
839 })
840 .detach();
841 }
842
843 fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
844 let Some(thread) = self.thread() else { return };
845
846 if self.is_loading_contents {
847 return;
848 }
849
850 self.history_store.update(cx, |history, cx| {
851 history.push_recently_opened_entry(
852 HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
853 cx,
854 );
855 });
856
857 if thread.read(cx).status() != ThreadStatus::Idle {
858 self.stop_current_and_send_new_message(window, cx);
859 return;
860 }
861
862 let contents = self
863 .message_editor
864 .update(cx, |message_editor, cx| message_editor.contents(cx));
865 self.send_impl(contents, window, cx)
866 }
867
868 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
869 let Some(thread) = self.thread().cloned() else {
870 return;
871 };
872
873 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
874
875 let contents = self
876 .message_editor
877 .update(cx, |message_editor, cx| message_editor.contents(cx));
878
879 cx.spawn_in(window, async move |this, cx| {
880 cancelled.await;
881
882 this.update_in(cx, |this, window, cx| {
883 this.send_impl(contents, window, cx);
884 })
885 .ok();
886 })
887 .detach();
888 }
889
890 fn send_impl(
891 &mut self,
892 contents: Task<anyhow::Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
893 window: &mut Window,
894 cx: &mut Context<Self>,
895 ) {
896 let agent_telemetry_id = self.agent.telemetry_id();
897
898 self.thread_error.take();
899 self.editing_message.take();
900 self.thread_feedback.clear();
901
902 let Some(thread) = self.thread().cloned() else {
903 return;
904 };
905 if self.should_be_following {
906 self.workspace
907 .update(cx, |workspace, cx| {
908 workspace.follow(CollaboratorId::Agent, window, cx);
909 })
910 .ok();
911 }
912
913 self.is_loading_contents = true;
914 let guard = cx.new(|_| ());
915 cx.observe_release(&guard, |this, _guard, cx| {
916 this.is_loading_contents = false;
917 cx.notify();
918 })
919 .detach();
920
921 let task = cx.spawn_in(window, async move |this, cx| {
922 let (contents, tracked_buffers) = contents.await?;
923
924 if contents.is_empty() {
925 return Ok(());
926 }
927
928 this.update_in(cx, |this, window, cx| {
929 this.set_editor_is_expanded(false, cx);
930 this.scroll_to_bottom(cx);
931 this.message_editor.update(cx, |message_editor, cx| {
932 message_editor.clear(window, cx);
933 });
934 })?;
935 let send = thread.update(cx, |thread, cx| {
936 thread.action_log().update(cx, |action_log, cx| {
937 for buffer in tracked_buffers {
938 action_log.buffer_read(buffer, cx)
939 }
940 });
941 drop(guard);
942
943 telemetry::event!("Agent Message Sent", agent = agent_telemetry_id);
944
945 thread.send(contents, cx)
946 })?;
947 send.await
948 });
949
950 cx.spawn(async move |this, cx| {
951 if let Err(err) = task.await {
952 this.update(cx, |this, cx| {
953 this.handle_thread_error(err, cx);
954 })
955 .ok();
956 } else {
957 this.update(cx, |this, cx| {
958 this.should_be_following = this
959 .workspace
960 .update(cx, |workspace, _| {
961 workspace.is_being_followed(CollaboratorId::Agent)
962 })
963 .unwrap_or_default();
964 })
965 .ok();
966 }
967 })
968 .detach();
969 }
970
971 fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
972 let Some(thread) = self.thread().cloned() else {
973 return;
974 };
975
976 if let Some(index) = self.editing_message.take()
977 && let Some(editor) = self
978 .entry_view_state
979 .read(cx)
980 .entry(index)
981 .and_then(|e| e.message_editor())
982 .cloned()
983 {
984 editor.update(cx, |editor, cx| {
985 if let Some(user_message) = thread
986 .read(cx)
987 .entries()
988 .get(index)
989 .and_then(|e| e.user_message())
990 {
991 editor.set_message(user_message.chunks.clone(), window, cx);
992 }
993 })
994 };
995 self.focus_handle(cx).focus(window);
996 cx.notify();
997 }
998
999 fn regenerate(
1000 &mut self,
1001 entry_ix: usize,
1002 message_editor: &Entity<MessageEditor>,
1003 window: &mut Window,
1004 cx: &mut Context<Self>,
1005 ) {
1006 let Some(thread) = self.thread().cloned() else {
1007 return;
1008 };
1009 if self.is_loading_contents {
1010 return;
1011 }
1012
1013 let Some(user_message_id) = thread.update(cx, |thread, _| {
1014 thread.entries().get(entry_ix)?.user_message()?.id.clone()
1015 }) else {
1016 return;
1017 };
1018
1019 let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
1020
1021 let task = cx.spawn(async move |_, cx| {
1022 let contents = contents.await?;
1023 thread
1024 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
1025 .await?;
1026 Ok(contents)
1027 });
1028 self.send_impl(task, window, cx);
1029 }
1030
1031 fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
1032 if let Some(thread) = self.thread() {
1033 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
1034 }
1035 }
1036
1037 fn open_edited_buffer(
1038 &mut self,
1039 buffer: &Entity<Buffer>,
1040 window: &mut Window,
1041 cx: &mut Context<Self>,
1042 ) {
1043 let Some(thread) = self.thread() else {
1044 return;
1045 };
1046
1047 let Some(diff) =
1048 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
1049 else {
1050 return;
1051 };
1052
1053 diff.update(cx, |diff, cx| {
1054 diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
1055 })
1056 }
1057
1058 fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1059 let Some(thread) = self.as_native_thread(cx) else {
1060 return;
1061 };
1062 let project_context = thread.read(cx).project_context().read(cx);
1063
1064 let project_entry_ids = project_context
1065 .worktrees
1066 .iter()
1067 .flat_map(|worktree| worktree.rules_file.as_ref())
1068 .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
1069 .collect::<Vec<_>>();
1070
1071 self.workspace
1072 .update(cx, move |workspace, cx| {
1073 // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
1074 // files clear. For example, if rules file 1 is already open but rules file 2 is not,
1075 // this would open and focus rules file 2 in a tab that is not next to rules file 1.
1076 let project = workspace.project().read(cx);
1077 let project_paths = project_entry_ids
1078 .into_iter()
1079 .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
1080 .collect::<Vec<_>>();
1081 for project_path in project_paths {
1082 workspace
1083 .open_path(project_path, None, true, window, cx)
1084 .detach_and_log_err(cx);
1085 }
1086 })
1087 .ok();
1088 }
1089
1090 fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
1091 self.thread_error = Some(ThreadError::from_err(error, &self.agent));
1092 cx.notify();
1093 }
1094
1095 fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
1096 self.thread_error = None;
1097 cx.notify();
1098 }
1099
1100 fn handle_thread_event(
1101 &mut self,
1102 thread: &Entity<AcpThread>,
1103 event: &AcpThreadEvent,
1104 window: &mut Window,
1105 cx: &mut Context<Self>,
1106 ) {
1107 match event {
1108 AcpThreadEvent::NewEntry => {
1109 let len = thread.read(cx).entries().len();
1110 let index = len - 1;
1111 self.entry_view_state.update(cx, |view_state, cx| {
1112 view_state.sync_entry(index, thread, window, cx)
1113 });
1114 self.list_state.splice(index..index, 1);
1115 }
1116 AcpThreadEvent::EntryUpdated(index) => {
1117 self.entry_view_state.update(cx, |view_state, cx| {
1118 view_state.sync_entry(*index, thread, window, cx)
1119 });
1120 }
1121 AcpThreadEvent::EntriesRemoved(range) => {
1122 self.entry_view_state
1123 .update(cx, |view_state, _cx| view_state.remove(range.clone()));
1124 self.list_state.splice(range.clone(), 0);
1125 }
1126 AcpThreadEvent::ToolAuthorizationRequired => {
1127 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1128 }
1129 AcpThreadEvent::Retry(retry) => {
1130 self.thread_retry_status = Some(retry.clone());
1131 }
1132 AcpThreadEvent::Stopped => {
1133 self.thread_retry_status.take();
1134 let used_tools = thread.read(cx).used_tools_since_last_user_message();
1135 self.notify_with_sound(
1136 if used_tools {
1137 "Finished running tools"
1138 } else {
1139 "New message"
1140 },
1141 IconName::ZedAssistant,
1142 window,
1143 cx,
1144 );
1145 }
1146 AcpThreadEvent::Error => {
1147 self.thread_retry_status.take();
1148 self.notify_with_sound(
1149 "Agent stopped due to an error",
1150 IconName::Warning,
1151 window,
1152 cx,
1153 );
1154 }
1155 AcpThreadEvent::LoadError(error) => {
1156 self.thread_retry_status.take();
1157 self.thread_state = ThreadState::LoadError(error.clone());
1158 if self.message_editor.focus_handle(cx).is_focused(window) {
1159 self.focus_handle.focus(window)
1160 }
1161 }
1162 AcpThreadEvent::TitleUpdated => {
1163 let title = thread.read(cx).title();
1164 if let Some(title_editor) = self.title_editor() {
1165 title_editor.update(cx, |editor, cx| {
1166 if editor.text(cx) != title {
1167 editor.set_text(title, window, cx);
1168 }
1169 });
1170 }
1171 }
1172 AcpThreadEvent::PromptCapabilitiesUpdated => {
1173 self.prompt_capabilities
1174 .set(thread.read(cx).prompt_capabilities());
1175 }
1176 AcpThreadEvent::TokenUsageUpdated => {}
1177 }
1178 cx.notify();
1179 }
1180
1181 fn authenticate(
1182 &mut self,
1183 method: acp::AuthMethodId,
1184 window: &mut Window,
1185 cx: &mut Context<Self>,
1186 ) {
1187 let ThreadState::Unauthenticated {
1188 connection,
1189 pending_auth_method,
1190 configuration_view,
1191 ..
1192 } = &mut self.thread_state
1193 else {
1194 return;
1195 };
1196
1197 if method.0.as_ref() == "gemini-api-key" {
1198 let registry = LanguageModelRegistry::global(cx);
1199 let provider = registry
1200 .read(cx)
1201 .provider(&language_model::GOOGLE_PROVIDER_ID)
1202 .unwrap();
1203 if !provider.is_authenticated(cx) {
1204 let this = cx.weak_entity();
1205 let agent = self.agent.clone();
1206 let connection = connection.clone();
1207 window.defer(cx, |window, cx| {
1208 Self::handle_auth_required(
1209 this,
1210 AuthRequired {
1211 description: Some("GEMINI_API_KEY must be set".to_owned()),
1212 provider_id: Some(language_model::GOOGLE_PROVIDER_ID),
1213 },
1214 agent,
1215 connection,
1216 window,
1217 cx,
1218 );
1219 });
1220 return;
1221 }
1222 } else if method.0.as_ref() == "vertex-ai"
1223 && std::env::var("GOOGLE_API_KEY").is_err()
1224 && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
1225 || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()))
1226 {
1227 let this = cx.weak_entity();
1228 let agent = self.agent.clone();
1229 let connection = connection.clone();
1230
1231 window.defer(cx, |window, cx| {
1232 Self::handle_auth_required(
1233 this,
1234 AuthRequired {
1235 description: Some(
1236 "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed."
1237 .to_owned(),
1238 ),
1239 provider_id: None,
1240 },
1241 agent,
1242 connection,
1243 window,
1244 cx,
1245 )
1246 });
1247 return;
1248 }
1249
1250 self.thread_error.take();
1251 configuration_view.take();
1252 pending_auth_method.replace(method.clone());
1253 let authenticate = connection.authenticate(method, cx);
1254 cx.notify();
1255 self.auth_task =
1256 Some(cx.spawn_in(window, {
1257 let project = self.project.clone();
1258 let agent = self.agent.clone();
1259 async move |this, cx| {
1260 let result = authenticate.await;
1261
1262 match &result {
1263 Ok(_) => telemetry::event!(
1264 "Authenticate Agent Succeeded",
1265 agent = agent.telemetry_id()
1266 ),
1267 Err(_) => {
1268 telemetry::event!(
1269 "Authenticate Agent Failed",
1270 agent = agent.telemetry_id(),
1271 )
1272 }
1273 }
1274
1275 this.update_in(cx, |this, window, cx| {
1276 if let Err(err) = result {
1277 this.handle_thread_error(err, cx);
1278 } else {
1279 this.thread_state = Self::initial_state(
1280 agent,
1281 None,
1282 this.workspace.clone(),
1283 project.clone(),
1284 window,
1285 cx,
1286 )
1287 }
1288 this.auth_task.take()
1289 })
1290 .ok();
1291 }
1292 }));
1293 }
1294
1295 fn authorize_tool_call(
1296 &mut self,
1297 tool_call_id: acp::ToolCallId,
1298 option_id: acp::PermissionOptionId,
1299 option_kind: acp::PermissionOptionKind,
1300 window: &mut Window,
1301 cx: &mut Context<Self>,
1302 ) {
1303 let Some(thread) = self.thread() else {
1304 return;
1305 };
1306 thread.update(cx, |thread, cx| {
1307 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
1308 });
1309 if self.should_be_following {
1310 self.workspace
1311 .update(cx, |workspace, cx| {
1312 workspace.follow(CollaboratorId::Agent, window, cx);
1313 })
1314 .ok();
1315 }
1316 cx.notify();
1317 }
1318
1319 fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
1320 let Some(thread) = self.thread() else {
1321 return;
1322 };
1323 thread
1324 .update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
1325 .detach_and_log_err(cx);
1326 cx.notify();
1327 }
1328
1329 fn render_entry(
1330 &self,
1331 entry_ix: usize,
1332 total_entries: usize,
1333 entry: &AgentThreadEntry,
1334 window: &mut Window,
1335 cx: &Context<Self>,
1336 ) -> AnyElement {
1337 let primary = match &entry {
1338 AgentThreadEntry::UserMessage(message) => {
1339 let Some(editor) = self
1340 .entry_view_state
1341 .read(cx)
1342 .entry(entry_ix)
1343 .and_then(|entry| entry.message_editor())
1344 .cloned()
1345 else {
1346 return Empty.into_any_element();
1347 };
1348
1349 let editing = self.editing_message == Some(entry_ix);
1350 let editor_focus = editor.focus_handle(cx).is_focused(window);
1351 let focus_border = cx.theme().colors().border_focused;
1352
1353 let rules_item = if entry_ix == 0 {
1354 self.render_rules_item(cx)
1355 } else {
1356 None
1357 };
1358
1359 let has_checkpoint_button = message
1360 .checkpoint
1361 .as_ref()
1362 .is_some_and(|checkpoint| checkpoint.show);
1363
1364 let agent_name = self.agent.name();
1365
1366 v_flex()
1367 .id(("user_message", entry_ix))
1368 .map(|this| {
1369 if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
1370 this.pt_4()
1371 } else if rules_item.is_some() {
1372 this.pt_3()
1373 } else {
1374 this.pt_2()
1375 }
1376 })
1377 .pb_4()
1378 .px_2()
1379 .gap_1p5()
1380 .w_full()
1381 .children(rules_item)
1382 .children(message.id.clone().and_then(|message_id| {
1383 message.checkpoint.as_ref()?.show.then(|| {
1384 h_flex()
1385 .px_3()
1386 .gap_2()
1387 .child(Divider::horizontal())
1388 .child(
1389 Button::new("restore-checkpoint", "Restore Checkpoint")
1390 .icon(IconName::Undo)
1391 .icon_size(IconSize::XSmall)
1392 .icon_position(IconPosition::Start)
1393 .label_size(LabelSize::XSmall)
1394 .icon_color(Color::Muted)
1395 .color(Color::Muted)
1396 .on_click(cx.listener(move |this, _, _window, cx| {
1397 this.rewind(&message_id, cx);
1398 }))
1399 )
1400 .child(Divider::horizontal())
1401 })
1402 }))
1403 .child(
1404 div()
1405 .relative()
1406 .child(
1407 div()
1408 .py_3()
1409 .px_2()
1410 .rounded_md()
1411 .shadow_md()
1412 .bg(cx.theme().colors().editor_background)
1413 .border_1()
1414 .when(editing && !editor_focus, |this| this.border_dashed())
1415 .border_color(cx.theme().colors().border)
1416 .map(|this|{
1417 if editing && editor_focus {
1418 this.border_color(focus_border)
1419 } else if message.id.is_some() {
1420 this.hover(|s| s.border_color(focus_border.opacity(0.8)))
1421 } else {
1422 this
1423 }
1424 })
1425 .text_xs()
1426 .child(editor.clone().into_any_element()),
1427 )
1428 .when(editor_focus, |this| {
1429 let base_container = h_flex()
1430 .absolute()
1431 .top_neg_3p5()
1432 .right_3()
1433 .gap_1()
1434 .rounded_sm()
1435 .border_1()
1436 .border_color(cx.theme().colors().border)
1437 .bg(cx.theme().colors().editor_background)
1438 .overflow_hidden();
1439
1440 if message.id.is_some() {
1441 this.child(
1442 base_container
1443 .child(
1444 IconButton::new("cancel", IconName::Close)
1445 .disabled(self.is_loading_contents)
1446 .icon_color(Color::Error)
1447 .icon_size(IconSize::XSmall)
1448 .on_click(cx.listener(Self::cancel_editing))
1449 )
1450 .child(
1451 if self.is_loading_contents {
1452 div()
1453 .id("loading-edited-message-content")
1454 .tooltip(Tooltip::text("Loading Added Context…"))
1455 .child(loading_contents_spinner(IconSize::XSmall))
1456 .into_any_element()
1457 } else {
1458 IconButton::new("regenerate", IconName::Return)
1459 .icon_color(Color::Muted)
1460 .icon_size(IconSize::XSmall)
1461 .tooltip(Tooltip::text(
1462 "Editing will restart the thread from this point."
1463 ))
1464 .on_click(cx.listener({
1465 let editor = editor.clone();
1466 move |this, _, window, cx| {
1467 this.regenerate(
1468 entry_ix, &editor, window, cx,
1469 );
1470 }
1471 })).into_any_element()
1472 }
1473 )
1474 )
1475 } else {
1476 this.child(
1477 base_container
1478 .border_dashed()
1479 .child(
1480 IconButton::new("editing_unavailable", IconName::PencilUnavailable)
1481 .icon_size(IconSize::Small)
1482 .icon_color(Color::Muted)
1483 .style(ButtonStyle::Transparent)
1484 .tooltip(move |_window, cx| {
1485 cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
1486 .into()
1487 })
1488 )
1489 )
1490 }
1491 }),
1492 )
1493 .into_any()
1494 }
1495 AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
1496 let style = default_markdown_style(false, false, window, cx);
1497 let message_body = v_flex()
1498 .w_full()
1499 .gap_2p5()
1500 .children(chunks.iter().enumerate().filter_map(
1501 |(chunk_ix, chunk)| match chunk {
1502 AssistantMessageChunk::Message { block } => {
1503 block.markdown().map(|md| {
1504 self.render_markdown(md.clone(), style.clone())
1505 .into_any_element()
1506 })
1507 }
1508 AssistantMessageChunk::Thought { block } => {
1509 block.markdown().map(|md| {
1510 self.render_thinking_block(
1511 entry_ix,
1512 chunk_ix,
1513 md.clone(),
1514 window,
1515 cx,
1516 )
1517 .into_any_element()
1518 })
1519 }
1520 },
1521 ))
1522 .into_any();
1523
1524 v_flex()
1525 .px_5()
1526 .py_1()
1527 .when(entry_ix + 1 == total_entries, |this| this.pb_4())
1528 .w_full()
1529 .text_ui(cx)
1530 .child(message_body)
1531 .into_any()
1532 }
1533 AgentThreadEntry::ToolCall(tool_call) => {
1534 let has_terminals = tool_call.terminals().next().is_some();
1535
1536 div().w_full().py_1().px_5().map(|this| {
1537 if has_terminals {
1538 this.children(tool_call.terminals().map(|terminal| {
1539 self.render_terminal_tool_call(
1540 entry_ix, terminal, tool_call, window, cx,
1541 )
1542 }))
1543 } else {
1544 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
1545 }
1546 })
1547 }
1548 .into_any(),
1549 };
1550
1551 let Some(thread) = self.thread() else {
1552 return primary;
1553 };
1554
1555 let primary = if entry_ix == total_entries - 1 {
1556 v_flex()
1557 .w_full()
1558 .child(primary)
1559 .child(self.render_thread_controls(&thread, cx))
1560 .when_some(
1561 self.thread_feedback.comments_editor.clone(),
1562 |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
1563 )
1564 .into_any_element()
1565 } else {
1566 primary
1567 };
1568
1569 if let Some(editing_index) = self.editing_message.as_ref()
1570 && *editing_index < entry_ix
1571 {
1572 let backdrop = div()
1573 .id(("backdrop", entry_ix))
1574 .size_full()
1575 .absolute()
1576 .inset_0()
1577 .bg(cx.theme().colors().panel_background)
1578 .opacity(0.8)
1579 .block_mouse_except_scroll()
1580 .on_click(cx.listener(Self::cancel_editing));
1581
1582 div()
1583 .relative()
1584 .child(primary)
1585 .child(backdrop)
1586 .into_any_element()
1587 } else {
1588 primary
1589 }
1590 }
1591
1592 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
1593 cx.theme()
1594 .colors()
1595 .element_background
1596 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
1597 }
1598
1599 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
1600 cx.theme().colors().border.opacity(0.8)
1601 }
1602
1603 fn tool_name_font_size(&self) -> Rems {
1604 rems_from_px(13.)
1605 }
1606
1607 fn render_thinking_block(
1608 &self,
1609 entry_ix: usize,
1610 chunk_ix: usize,
1611 chunk: Entity<Markdown>,
1612 window: &Window,
1613 cx: &Context<Self>,
1614 ) -> AnyElement {
1615 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
1616 let card_header_id = SharedString::from("inner-card-header");
1617 let key = (entry_ix, chunk_ix);
1618 let is_open = self.expanded_thinking_blocks.contains(&key);
1619
1620 v_flex()
1621 .child(
1622 h_flex()
1623 .id(header_id)
1624 .group(&card_header_id)
1625 .relative()
1626 .w_full()
1627 .gap_1p5()
1628 .child(
1629 h_flex()
1630 .size_4()
1631 .justify_center()
1632 .child(
1633 div()
1634 .group_hover(&card_header_id, |s| s.invisible().w_0())
1635 .child(
1636 Icon::new(IconName::ToolThink)
1637 .size(IconSize::Small)
1638 .color(Color::Muted),
1639 ),
1640 )
1641 .child(
1642 h_flex()
1643 .absolute()
1644 .inset_0()
1645 .invisible()
1646 .justify_center()
1647 .group_hover(&card_header_id, |s| s.visible())
1648 .child(
1649 Disclosure::new(("expand", entry_ix), is_open)
1650 .opened_icon(IconName::ChevronUp)
1651 .closed_icon(IconName::ChevronRight)
1652 .on_click(cx.listener({
1653 move |this, _event, _window, cx| {
1654 if is_open {
1655 this.expanded_thinking_blocks.remove(&key);
1656 } else {
1657 this.expanded_thinking_blocks.insert(key);
1658 }
1659 cx.notify();
1660 }
1661 })),
1662 ),
1663 ),
1664 )
1665 .child(
1666 div()
1667 .text_size(self.tool_name_font_size())
1668 .text_color(cx.theme().colors().text_muted)
1669 .child("Thinking"),
1670 )
1671 .on_click(cx.listener({
1672 move |this, _event, _window, cx| {
1673 if is_open {
1674 this.expanded_thinking_blocks.remove(&key);
1675 } else {
1676 this.expanded_thinking_blocks.insert(key);
1677 }
1678 cx.notify();
1679 }
1680 })),
1681 )
1682 .when(is_open, |this| {
1683 this.child(
1684 div()
1685 .relative()
1686 .mt_1p5()
1687 .ml(rems(0.4))
1688 .pl_4()
1689 .border_l_1()
1690 .border_color(self.tool_card_border_color(cx))
1691 .text_ui_sm(cx)
1692 .child(self.render_markdown(
1693 chunk,
1694 default_markdown_style(false, false, window, cx),
1695 )),
1696 )
1697 })
1698 .into_any_element()
1699 }
1700
1701 fn render_tool_call(
1702 &self,
1703 entry_ix: usize,
1704 tool_call: &ToolCall,
1705 window: &Window,
1706 cx: &Context<Self>,
1707 ) -> Div {
1708 let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
1709 let card_header_id = SharedString::from("inner-tool-call-header");
1710
1711 let tool_icon =
1712 if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 {
1713 FileIcons::get_icon(&tool_call.locations[0].path, cx)
1714 .map(Icon::from_path)
1715 .unwrap_or(Icon::new(IconName::ToolPencil))
1716 } else {
1717 Icon::new(match tool_call.kind {
1718 acp::ToolKind::Read => IconName::ToolSearch,
1719 acp::ToolKind::Edit => IconName::ToolPencil,
1720 acp::ToolKind::Delete => IconName::ToolDeleteFile,
1721 acp::ToolKind::Move => IconName::ArrowRightLeft,
1722 acp::ToolKind::Search => IconName::ToolSearch,
1723 acp::ToolKind::Execute => IconName::ToolTerminal,
1724 acp::ToolKind::Think => IconName::ToolThink,
1725 acp::ToolKind::Fetch => IconName::ToolWeb,
1726 acp::ToolKind::Other => IconName::ToolHammer,
1727 })
1728 }
1729 .size(IconSize::Small)
1730 .color(Color::Muted);
1731
1732 let failed_or_canceled = match &tool_call.status {
1733 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
1734 _ => false,
1735 };
1736
1737 let failed_tool_call = matches!(
1738 tool_call.status,
1739 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
1740 );
1741
1742 let needs_confirmation = matches!(
1743 tool_call.status,
1744 ToolCallStatus::WaitingForConfirmation { .. }
1745 );
1746 let is_edit =
1747 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
1748 let use_card_layout = needs_confirmation || is_edit;
1749
1750 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
1751
1752 let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
1753
1754 let gradient_overlay = |color: Hsla| {
1755 div()
1756 .absolute()
1757 .top_0()
1758 .right_0()
1759 .w_12()
1760 .h_full()
1761 .bg(linear_gradient(
1762 90.,
1763 linear_color_stop(color, 1.),
1764 linear_color_stop(color.opacity(0.2), 0.),
1765 ))
1766 };
1767 let gradient_color = if use_card_layout {
1768 self.tool_card_header_bg(cx)
1769 } else {
1770 cx.theme().colors().panel_background
1771 };
1772
1773 let tool_output_display = if is_open {
1774 match &tool_call.status {
1775 ToolCallStatus::WaitingForConfirmation { options, .. } => {
1776 v_flex()
1777 .w_full()
1778 .children(tool_call.content.iter().map(|content| {
1779 div()
1780 .child(self.render_tool_call_content(
1781 entry_ix, content, tool_call, window, cx,
1782 ))
1783 .into_any_element()
1784 }))
1785 .child(self.render_permission_buttons(
1786 options,
1787 entry_ix,
1788 tool_call.id.clone(),
1789 tool_call.content.is_empty(),
1790 cx,
1791 ))
1792 .into_any()
1793 }
1794 ToolCallStatus::Pending | ToolCallStatus::InProgress
1795 if is_edit
1796 && tool_call.content.is_empty()
1797 && self.as_native_connection(cx).is_some() =>
1798 {
1799 self.render_diff_loading(cx).into_any()
1800 }
1801 ToolCallStatus::Pending
1802 | ToolCallStatus::InProgress
1803 | ToolCallStatus::Completed
1804 | ToolCallStatus::Failed
1805 | ToolCallStatus::Canceled => v_flex()
1806 .w_full()
1807 .children(tool_call.content.iter().map(|content| {
1808 div().child(
1809 self.render_tool_call_content(entry_ix, content, tool_call, window, cx),
1810 )
1811 }))
1812 .into_any(),
1813 ToolCallStatus::Rejected => Empty.into_any(),
1814 }
1815 .into()
1816 } else {
1817 None
1818 };
1819
1820 v_flex()
1821 .when(use_card_layout, |this| {
1822 this.rounded_md()
1823 .border_1()
1824 .border_color(self.tool_card_border_color(cx))
1825 .bg(cx.theme().colors().editor_background)
1826 .overflow_hidden()
1827 })
1828 .child(
1829 h_flex()
1830 .id(header_id)
1831 .group(&card_header_id)
1832 .relative()
1833 .w_full()
1834 .max_w_full()
1835 .gap_1()
1836 .when(use_card_layout, |this| {
1837 this.pl_1p5()
1838 .pr_1()
1839 .py_0p5()
1840 .rounded_t_md()
1841 .when(is_open && !failed_tool_call, |this| {
1842 this.border_b_1()
1843 .border_color(self.tool_card_border_color(cx))
1844 })
1845 .bg(self.tool_card_header_bg(cx))
1846 })
1847 .child(
1848 h_flex()
1849 .relative()
1850 .w_full()
1851 .h(window.line_height() - px(2.))
1852 .text_size(self.tool_name_font_size())
1853 .gap_0p5()
1854 .child(tool_icon)
1855 .child(if tool_call.locations.len() == 1 {
1856 let name = tool_call.locations[0]
1857 .path
1858 .file_name()
1859 .unwrap_or_default()
1860 .display()
1861 .to_string();
1862
1863 h_flex()
1864 .id(("open-tool-call-location", entry_ix))
1865 .w_full()
1866 .max_w_full()
1867 .px_1p5()
1868 .rounded_sm()
1869 .overflow_x_scroll()
1870 .hover(|label| {
1871 label.bg(cx.theme().colors().element_hover.opacity(0.5))
1872 })
1873 .map(|this| {
1874 if use_card_layout {
1875 this.text_color(cx.theme().colors().text)
1876 } else {
1877 this.text_color(cx.theme().colors().text_muted)
1878 }
1879 })
1880 .child(name)
1881 .tooltip(Tooltip::text("Jump to File"))
1882 .cursor(gpui::CursorStyle::PointingHand)
1883 .on_click(cx.listener(move |this, _, window, cx| {
1884 this.open_tool_call_location(entry_ix, 0, window, cx);
1885 }))
1886 .into_any_element()
1887 } else {
1888 h_flex()
1889 .relative()
1890 .w_full()
1891 .max_w_full()
1892 .ml_1p5()
1893 .overflow_hidden()
1894 .child(h_flex().pr_8().child(self.render_markdown(
1895 tool_call.label.clone(),
1896 default_markdown_style(false, true, window, cx),
1897 )))
1898 .child(gradient_overlay(gradient_color))
1899 .into_any()
1900 }),
1901 )
1902 .child(
1903 h_flex()
1904 .gap_px()
1905 .when(is_collapsible, |this| {
1906 this.child(
1907 Disclosure::new(("expand", entry_ix), is_open)
1908 .opened_icon(IconName::ChevronUp)
1909 .closed_icon(IconName::ChevronDown)
1910 .visible_on_hover(&card_header_id)
1911 .on_click(cx.listener({
1912 let id = tool_call.id.clone();
1913 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1914 if is_open {
1915 this.expanded_tool_calls.remove(&id);
1916 } else {
1917 this.expanded_tool_calls.insert(id.clone());
1918 }
1919 cx.notify();
1920 }
1921 })),
1922 )
1923 })
1924 .when(failed_or_canceled, |this| {
1925 this.child(
1926 Icon::new(IconName::Close)
1927 .color(Color::Error)
1928 .size(IconSize::Small),
1929 )
1930 }),
1931 ),
1932 )
1933 .children(tool_output_display)
1934 }
1935
1936 fn render_tool_call_content(
1937 &self,
1938 entry_ix: usize,
1939 content: &ToolCallContent,
1940 tool_call: &ToolCall,
1941 window: &Window,
1942 cx: &Context<Self>,
1943 ) -> AnyElement {
1944 match content {
1945 ToolCallContent::ContentBlock(content) => {
1946 if let Some(resource_link) = content.resource_link() {
1947 self.render_resource_link(resource_link, cx)
1948 } else if let Some(markdown) = content.markdown() {
1949 self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx)
1950 } else {
1951 Empty.into_any_element()
1952 }
1953 }
1954 ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, tool_call, cx),
1955 ToolCallContent::Terminal(terminal) => {
1956 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
1957 }
1958 }
1959 }
1960
1961 fn render_markdown_output(
1962 &self,
1963 markdown: Entity<Markdown>,
1964 tool_call_id: acp::ToolCallId,
1965 window: &Window,
1966 cx: &Context<Self>,
1967 ) -> AnyElement {
1968 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
1969
1970 v_flex()
1971 .mt_1p5()
1972 .ml(rems(0.4))
1973 .px_3p5()
1974 .gap_2()
1975 .border_l_1()
1976 .border_color(self.tool_card_border_color(cx))
1977 .text_sm()
1978 .text_color(cx.theme().colors().text_muted)
1979 .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
1980 .child(
1981 IconButton::new(button_id, IconName::ChevronUp)
1982 .full_width()
1983 .style(ButtonStyle::Outlined)
1984 .icon_color(Color::Muted)
1985 .on_click(cx.listener({
1986 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1987 this.expanded_tool_calls.remove(&tool_call_id);
1988 cx.notify();
1989 }
1990 })),
1991 )
1992 .into_any_element()
1993 }
1994
1995 fn render_resource_link(
1996 &self,
1997 resource_link: &acp::ResourceLink,
1998 cx: &Context<Self>,
1999 ) -> AnyElement {
2000 let uri: SharedString = resource_link.uri.clone().into();
2001 let is_file = resource_link.uri.strip_prefix("file://");
2002
2003 let label: SharedString = if let Some(abs_path) = is_file {
2004 if let Some(project_path) = self
2005 .project
2006 .read(cx)
2007 .project_path_for_absolute_path(&Path::new(abs_path), cx)
2008 && let Some(worktree) = self
2009 .project
2010 .read(cx)
2011 .worktree_for_id(project_path.worktree_id, cx)
2012 {
2013 worktree
2014 .read(cx)
2015 .full_path(&project_path.path)
2016 .to_string_lossy()
2017 .to_string()
2018 .into()
2019 } else {
2020 abs_path.to_string().into()
2021 }
2022 } else {
2023 uri.clone()
2024 };
2025
2026 let button_id = SharedString::from(format!("item-{}", uri));
2027
2028 div()
2029 .ml(rems(0.4))
2030 .pl_2p5()
2031 .border_l_1()
2032 .border_color(self.tool_card_border_color(cx))
2033 .overflow_hidden()
2034 .child(
2035 Button::new(button_id, label)
2036 .label_size(LabelSize::Small)
2037 .color(Color::Muted)
2038 .truncate(true)
2039 .when(is_file.is_none(), |this| {
2040 this.icon(IconName::ArrowUpRight)
2041 .icon_size(IconSize::XSmall)
2042 .icon_color(Color::Muted)
2043 })
2044 .on_click(cx.listener({
2045 let workspace = self.workspace.clone();
2046 move |_, _, window, cx: &mut Context<Self>| {
2047 Self::open_link(uri.clone(), &workspace, window, cx);
2048 }
2049 })),
2050 )
2051 .into_any_element()
2052 }
2053
2054 fn render_permission_buttons(
2055 &self,
2056 options: &[acp::PermissionOption],
2057 entry_ix: usize,
2058 tool_call_id: acp::ToolCallId,
2059 empty_content: bool,
2060 cx: &Context<Self>,
2061 ) -> Div {
2062 h_flex()
2063 .py_1()
2064 .pl_2()
2065 .pr_1()
2066 .gap_1()
2067 .justify_between()
2068 .flex_wrap()
2069 .when(!empty_content, |this| {
2070 this.border_t_1()
2071 .border_color(self.tool_card_border_color(cx))
2072 })
2073 .child(
2074 div()
2075 .min_w(rems_from_px(145.))
2076 .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
2077 )
2078 .child(h_flex().gap_0p5().children(options.iter().map(|option| {
2079 let option_id = SharedString::from(option.id.0.clone());
2080 Button::new((option_id, entry_ix), option.name.clone())
2081 .map(|this| match option.kind {
2082 acp::PermissionOptionKind::AllowOnce => {
2083 this.icon(IconName::Check).icon_color(Color::Success)
2084 }
2085 acp::PermissionOptionKind::AllowAlways => {
2086 this.icon(IconName::CheckDouble).icon_color(Color::Success)
2087 }
2088 acp::PermissionOptionKind::RejectOnce => {
2089 this.icon(IconName::Close).icon_color(Color::Error)
2090 }
2091 acp::PermissionOptionKind::RejectAlways => {
2092 this.icon(IconName::Close).icon_color(Color::Error)
2093 }
2094 })
2095 .icon_position(IconPosition::Start)
2096 .icon_size(IconSize::XSmall)
2097 .label_size(LabelSize::Small)
2098 .on_click(cx.listener({
2099 let tool_call_id = tool_call_id.clone();
2100 let option_id = option.id.clone();
2101 let option_kind = option.kind;
2102 move |this, _, window, cx| {
2103 this.authorize_tool_call(
2104 tool_call_id.clone(),
2105 option_id.clone(),
2106 option_kind,
2107 window,
2108 cx,
2109 );
2110 }
2111 }))
2112 })))
2113 }
2114
2115 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
2116 let bar = |n: u64, width_class: &str| {
2117 let bg_color = cx.theme().colors().element_active;
2118 let base = h_flex().h_1().rounded_full();
2119
2120 let modified = match width_class {
2121 "w_4_5" => base.w_3_4(),
2122 "w_1_4" => base.w_1_4(),
2123 "w_2_4" => base.w_2_4(),
2124 "w_3_5" => base.w_3_5(),
2125 "w_2_5" => base.w_2_5(),
2126 _ => base.w_1_2(),
2127 };
2128
2129 modified.with_animation(
2130 ElementId::Integer(n),
2131 Animation::new(Duration::from_secs(2)).repeat(),
2132 move |tab, delta| {
2133 let delta = (delta - 0.15 * n as f32) / 0.7;
2134 let delta = 1.0 - (0.5 - delta).abs() * 2.;
2135 let delta = ease_in_out(delta.clamp(0., 1.));
2136 let delta = 0.1 + 0.9 * delta;
2137
2138 tab.bg(bg_color.opacity(delta))
2139 },
2140 )
2141 };
2142
2143 v_flex()
2144 .p_3()
2145 .gap_1()
2146 .rounded_b_md()
2147 .bg(cx.theme().colors().editor_background)
2148 .child(bar(0, "w_4_5"))
2149 .child(bar(1, "w_1_4"))
2150 .child(bar(2, "w_2_4"))
2151 .child(bar(3, "w_3_5"))
2152 .child(bar(4, "w_2_5"))
2153 .into_any_element()
2154 }
2155
2156 fn render_diff_editor(
2157 &self,
2158 entry_ix: usize,
2159 diff: &Entity<acp_thread::Diff>,
2160 tool_call: &ToolCall,
2161 cx: &Context<Self>,
2162 ) -> AnyElement {
2163 let tool_progress = matches!(
2164 &tool_call.status,
2165 ToolCallStatus::InProgress | ToolCallStatus::Pending
2166 );
2167
2168 v_flex()
2169 .h_full()
2170 .child(
2171 if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
2172 && let Some(editor) = entry.editor_for_diff(diff)
2173 && diff.read(cx).has_revealed_range(cx)
2174 {
2175 editor.into_any_element()
2176 } else if tool_progress && self.as_native_connection(cx).is_some() {
2177 self.render_diff_loading(cx)
2178 } else {
2179 Empty.into_any()
2180 },
2181 )
2182 .into_any()
2183 }
2184
2185 fn render_terminal_tool_call(
2186 &self,
2187 entry_ix: usize,
2188 terminal: &Entity<acp_thread::Terminal>,
2189 tool_call: &ToolCall,
2190 window: &Window,
2191 cx: &Context<Self>,
2192 ) -> AnyElement {
2193 let terminal_data = terminal.read(cx);
2194 let working_dir = terminal_data.working_dir();
2195 let command = terminal_data.command();
2196 let started_at = terminal_data.started_at();
2197
2198 let tool_failed = matches!(
2199 &tool_call.status,
2200 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
2201 );
2202
2203 let output = terminal_data.output();
2204 let command_finished = output.is_some();
2205 let truncated_output = output.is_some_and(|output| output.was_content_truncated);
2206 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
2207
2208 let command_failed = command_finished
2209 && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success()));
2210
2211 let time_elapsed = if let Some(output) = output {
2212 output.ended_at.duration_since(started_at)
2213 } else {
2214 started_at.elapsed()
2215 };
2216
2217 let header_bg = cx
2218 .theme()
2219 .colors()
2220 .element_background
2221 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
2222 let border_color = cx.theme().colors().border.opacity(0.6);
2223
2224 let working_dir = working_dir
2225 .as_ref()
2226 .map(|path| format!("{}", path.display()))
2227 .unwrap_or_else(|| "current directory".to_string());
2228
2229 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
2230
2231 let header = h_flex()
2232 .id(SharedString::from(format!(
2233 "terminal-tool-header-{}",
2234 terminal.entity_id()
2235 )))
2236 .flex_none()
2237 .gap_1()
2238 .justify_between()
2239 .rounded_t_md()
2240 .child(
2241 div()
2242 .id(("command-target-path", terminal.entity_id()))
2243 .w_full()
2244 .max_w_full()
2245 .overflow_x_scroll()
2246 .child(
2247 Label::new(working_dir)
2248 .buffer_font(cx)
2249 .size(LabelSize::XSmall)
2250 .color(Color::Muted),
2251 ),
2252 )
2253 .when(!command_finished, |header| {
2254 header
2255 .gap_1p5()
2256 .child(
2257 Button::new(
2258 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
2259 "Stop",
2260 )
2261 .icon(IconName::Stop)
2262 .icon_position(IconPosition::Start)
2263 .icon_size(IconSize::Small)
2264 .icon_color(Color::Error)
2265 .label_size(LabelSize::Small)
2266 .tooltip(move |window, cx| {
2267 Tooltip::with_meta(
2268 "Stop This Command",
2269 None,
2270 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
2271 window,
2272 cx,
2273 )
2274 })
2275 .on_click({
2276 let terminal = terminal.clone();
2277 cx.listener(move |_this, _event, _window, cx| {
2278 let inner_terminal = terminal.read(cx).inner().clone();
2279 inner_terminal.update(cx, |inner_terminal, _cx| {
2280 inner_terminal.kill_active_task();
2281 });
2282 })
2283 }),
2284 )
2285 .child(Divider::vertical())
2286 .child(
2287 Icon::new(IconName::ArrowCircle)
2288 .size(IconSize::XSmall)
2289 .color(Color::Info)
2290 .with_animation(
2291 "arrow-circle",
2292 Animation::new(Duration::from_secs(2)).repeat(),
2293 |icon, delta| {
2294 icon.transform(Transformation::rotate(percentage(delta)))
2295 },
2296 ),
2297 )
2298 })
2299 .when(tool_failed || command_failed, |header| {
2300 header.child(
2301 div()
2302 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
2303 .child(
2304 Icon::new(IconName::Close)
2305 .size(IconSize::Small)
2306 .color(Color::Error),
2307 )
2308 .when_some(output.and_then(|o| o.exit_status), |this, status| {
2309 this.tooltip(Tooltip::text(format!(
2310 "Exited with code {}",
2311 status.code().unwrap_or(-1),
2312 )))
2313 }),
2314 )
2315 })
2316 .when(truncated_output, |header| {
2317 let tooltip = if let Some(output) = output {
2318 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
2319 "Output exceeded terminal max lines and was \
2320 truncated, the model received the first 16 KB."
2321 .to_string()
2322 } else {
2323 format!(
2324 "Output is {} long, and to avoid unexpected token usage, \
2325 only 16 KB was sent back to the model.",
2326 format_file_size(output.original_content_len as u64, true),
2327 )
2328 }
2329 } else {
2330 "Output was truncated".to_string()
2331 };
2332
2333 header.child(
2334 h_flex()
2335 .id(("terminal-tool-truncated-label", terminal.entity_id()))
2336 .gap_1()
2337 .child(
2338 Icon::new(IconName::Info)
2339 .size(IconSize::XSmall)
2340 .color(Color::Ignored),
2341 )
2342 .child(
2343 Label::new("Truncated")
2344 .color(Color::Muted)
2345 .size(LabelSize::XSmall),
2346 )
2347 .tooltip(Tooltip::text(tooltip)),
2348 )
2349 })
2350 .when(time_elapsed > Duration::from_secs(10), |header| {
2351 header.child(
2352 Label::new(format!("({})", duration_alt_display(time_elapsed)))
2353 .buffer_font(cx)
2354 .color(Color::Muted)
2355 .size(LabelSize::XSmall),
2356 )
2357 })
2358 .child(
2359 Disclosure::new(
2360 SharedString::from(format!(
2361 "terminal-tool-disclosure-{}",
2362 terminal.entity_id()
2363 )),
2364 is_expanded,
2365 )
2366 .opened_icon(IconName::ChevronUp)
2367 .closed_icon(IconName::ChevronDown)
2368 .on_click(cx.listener({
2369 let id = tool_call.id.clone();
2370 move |this, _event, _window, _cx| {
2371 if is_expanded {
2372 this.expanded_tool_calls.remove(&id);
2373 } else {
2374 this.expanded_tool_calls.insert(id.clone());
2375 }
2376 }})),
2377 );
2378
2379 let terminal_view = self
2380 .entry_view_state
2381 .read(cx)
2382 .entry(entry_ix)
2383 .and_then(|entry| entry.terminal(terminal));
2384 let show_output = is_expanded && terminal_view.is_some();
2385
2386 v_flex()
2387 .mb_2()
2388 .border_1()
2389 .when(tool_failed || command_failed, |card| card.border_dashed())
2390 .border_color(border_color)
2391 .rounded_md()
2392 .overflow_hidden()
2393 .child(
2394 v_flex()
2395 .py_1p5()
2396 .pl_2()
2397 .pr_1p5()
2398 .gap_0p5()
2399 .bg(header_bg)
2400 .text_xs()
2401 .child(header)
2402 .child(
2403 MarkdownElement::new(
2404 command.clone(),
2405 terminal_command_markdown_style(window, cx),
2406 )
2407 .code_block_renderer(
2408 markdown::CodeBlockRenderer::Default {
2409 copy_button: false,
2410 copy_button_on_hover: true,
2411 border: false,
2412 },
2413 ),
2414 ),
2415 )
2416 .when(show_output, |this| {
2417 this.child(
2418 div()
2419 .pt_2()
2420 .border_t_1()
2421 .when(tool_failed || command_failed, |card| card.border_dashed())
2422 .border_color(border_color)
2423 .bg(cx.theme().colors().editor_background)
2424 .rounded_b_md()
2425 .text_ui_sm(cx)
2426 .children(terminal_view.clone()),
2427 )
2428 })
2429 .into_any()
2430 }
2431
2432 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
2433 let project_context = self
2434 .as_native_thread(cx)?
2435 .read(cx)
2436 .project_context()
2437 .read(cx);
2438
2439 let user_rules_text = if project_context.user_rules.is_empty() {
2440 None
2441 } else if project_context.user_rules.len() == 1 {
2442 let user_rules = &project_context.user_rules[0];
2443
2444 match user_rules.title.as_ref() {
2445 Some(title) => Some(format!("Using \"{title}\" user rule")),
2446 None => Some("Using user rule".into()),
2447 }
2448 } else {
2449 Some(format!(
2450 "Using {} user rules",
2451 project_context.user_rules.len()
2452 ))
2453 };
2454
2455 let first_user_rules_id = project_context
2456 .user_rules
2457 .first()
2458 .map(|user_rules| user_rules.uuid.0);
2459
2460 let rules_files = project_context
2461 .worktrees
2462 .iter()
2463 .filter_map(|worktree| worktree.rules_file.as_ref())
2464 .collect::<Vec<_>>();
2465
2466 let rules_file_text = match rules_files.as_slice() {
2467 &[] => None,
2468 &[rules_file] => Some(format!(
2469 "Using project {:?} file",
2470 rules_file.path_in_worktree
2471 )),
2472 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
2473 };
2474
2475 if user_rules_text.is_none() && rules_file_text.is_none() {
2476 return None;
2477 }
2478
2479 let has_both = user_rules_text.is_some() && rules_file_text.is_some();
2480
2481 Some(
2482 h_flex()
2483 .px_2p5()
2484 .child(
2485 Icon::new(IconName::Attach)
2486 .size(IconSize::XSmall)
2487 .color(Color::Disabled),
2488 )
2489 .when_some(user_rules_text, |parent, user_rules_text| {
2490 parent.child(
2491 h_flex()
2492 .id("user-rules")
2493 .ml_1()
2494 .mr_1p5()
2495 .child(
2496 Label::new(user_rules_text)
2497 .size(LabelSize::XSmall)
2498 .color(Color::Muted)
2499 .truncate(),
2500 )
2501 .hover(|s| s.bg(cx.theme().colors().element_hover))
2502 .tooltip(Tooltip::text("View User Rules"))
2503 .on_click(move |_event, window, cx| {
2504 window.dispatch_action(
2505 Box::new(OpenRulesLibrary {
2506 prompt_to_select: first_user_rules_id,
2507 }),
2508 cx,
2509 )
2510 }),
2511 )
2512 })
2513 .when(has_both, |this| {
2514 this.child(
2515 Label::new("•")
2516 .size(LabelSize::XSmall)
2517 .color(Color::Disabled),
2518 )
2519 })
2520 .when_some(rules_file_text, |parent, rules_file_text| {
2521 parent.child(
2522 h_flex()
2523 .id("project-rules")
2524 .ml_1p5()
2525 .child(
2526 Label::new(rules_file_text)
2527 .size(LabelSize::XSmall)
2528 .color(Color::Muted),
2529 )
2530 .hover(|s| s.bg(cx.theme().colors().element_hover))
2531 .tooltip(Tooltip::text("View Project Rules"))
2532 .on_click(cx.listener(Self::handle_open_rules)),
2533 )
2534 })
2535 .into_any(),
2536 )
2537 }
2538
2539 fn render_empty_state_section_header(
2540 &self,
2541 label: impl Into<SharedString>,
2542 action_slot: Option<AnyElement>,
2543 cx: &mut Context<Self>,
2544 ) -> impl IntoElement {
2545 div().pl_1().pr_1p5().child(
2546 h_flex()
2547 .mt_2()
2548 .pl_1p5()
2549 .pb_1()
2550 .w_full()
2551 .justify_between()
2552 .border_b_1()
2553 .border_color(cx.theme().colors().border_variant)
2554 .child(
2555 Label::new(label.into())
2556 .size(LabelSize::Small)
2557 .color(Color::Muted),
2558 )
2559 .children(action_slot),
2560 )
2561 }
2562
2563 fn render_recent_history(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2564 let render_history = self
2565 .agent
2566 .clone()
2567 .downcast::<agent2::NativeAgentServer>()
2568 .is_some()
2569 && self
2570 .history_store
2571 .update(cx, |history_store, cx| !history_store.is_empty(cx));
2572
2573 v_flex()
2574 .size_full()
2575 .when(render_history, |this| {
2576 let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| {
2577 history_store.entries().take(3).collect()
2578 });
2579 this.justify_end().child(
2580 v_flex()
2581 .child(
2582 self.render_empty_state_section_header(
2583 "Recent",
2584 Some(
2585 Button::new("view-history", "View All")
2586 .style(ButtonStyle::Subtle)
2587 .label_size(LabelSize::Small)
2588 .key_binding(
2589 KeyBinding::for_action_in(
2590 &OpenHistory,
2591 &self.focus_handle(cx),
2592 window,
2593 cx,
2594 )
2595 .map(|kb| kb.size(rems_from_px(12.))),
2596 )
2597 .on_click(move |_event, window, cx| {
2598 window.dispatch_action(OpenHistory.boxed_clone(), cx);
2599 })
2600 .into_any_element(),
2601 ),
2602 cx,
2603 ),
2604 )
2605 .child(
2606 v_flex().p_1().pr_1p5().gap_1().children(
2607 recent_history
2608 .into_iter()
2609 .enumerate()
2610 .map(|(index, entry)| {
2611 // TODO: Add keyboard navigation.
2612 let is_hovered =
2613 self.hovered_recent_history_item == Some(index);
2614 crate::acp::thread_history::AcpHistoryEntryElement::new(
2615 entry,
2616 cx.entity().downgrade(),
2617 )
2618 .hovered(is_hovered)
2619 .on_hover(cx.listener(
2620 move |this, is_hovered, _window, cx| {
2621 if *is_hovered {
2622 this.hovered_recent_history_item = Some(index);
2623 } else if this.hovered_recent_history_item
2624 == Some(index)
2625 {
2626 this.hovered_recent_history_item = None;
2627 }
2628 cx.notify();
2629 },
2630 ))
2631 .into_any_element()
2632 }),
2633 ),
2634 ),
2635 )
2636 })
2637 .into_any()
2638 }
2639
2640 fn render_auth_required_state(
2641 &self,
2642 connection: &Rc<dyn AgentConnection>,
2643 description: Option<&Entity<Markdown>>,
2644 configuration_view: Option<&AnyView>,
2645 pending_auth_method: Option<&acp::AuthMethodId>,
2646 window: &mut Window,
2647 cx: &Context<Self>,
2648 ) -> Div {
2649 let show_description =
2650 configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
2651
2652 v_flex().flex_1().size_full().justify_end().child(
2653 v_flex()
2654 .p_2()
2655 .pr_3()
2656 .w_full()
2657 .gap_1()
2658 .border_t_1()
2659 .border_color(cx.theme().colors().border)
2660 .bg(cx.theme().status().warning.opacity(0.04))
2661 .child(
2662 h_flex()
2663 .gap_1p5()
2664 .child(
2665 Icon::new(IconName::Warning)
2666 .color(Color::Warning)
2667 .size(IconSize::Small),
2668 )
2669 .child(Label::new("Authentication Required").size(LabelSize::Small)),
2670 )
2671 .children(description.map(|desc| {
2672 div().text_ui(cx).child(self.render_markdown(
2673 desc.clone(),
2674 default_markdown_style(false, false, window, cx),
2675 ))
2676 }))
2677 .children(
2678 configuration_view
2679 .cloned()
2680 .map(|view| div().w_full().child(view)),
2681 )
2682 .when(
2683 show_description,
2684 |el| {
2685 el.child(
2686 Label::new(format!(
2687 "You are not currently authenticated with {}. Please choose one of the following options:",
2688 self.agent.name()
2689 ))
2690 .size(LabelSize::Small)
2691 .color(Color::Muted)
2692 .mb_1()
2693 .ml_5(),
2694 )
2695 },
2696 )
2697 .when_some(pending_auth_method, |el, _| {
2698 el.child(
2699 h_flex()
2700 .py_4()
2701 .w_full()
2702 .justify_center()
2703 .gap_1()
2704 .child(
2705 Icon::new(IconName::ArrowCircle)
2706 .size(IconSize::Small)
2707 .color(Color::Muted)
2708 .with_animation(
2709 "arrow-circle",
2710 Animation::new(Duration::from_secs(2)).repeat(),
2711 |icon, delta| {
2712 icon.transform(Transformation::rotate(percentage(
2713 delta,
2714 )))
2715 },
2716 )
2717 .into_any_element(),
2718 )
2719 .child(Label::new("Authenticating…").size(LabelSize::Small)),
2720 )
2721 })
2722 .when(!connection.auth_methods().is_empty(), |this| {
2723 this.child(
2724 h_flex()
2725 .justify_end()
2726 .flex_wrap()
2727 .gap_1()
2728 .when(!show_description, |this| {
2729 this.border_t_1()
2730 .mt_1()
2731 .pt_2()
2732 .border_color(cx.theme().colors().border.opacity(0.8))
2733 })
2734 .children(
2735 connection
2736 .auth_methods()
2737 .iter()
2738 .enumerate()
2739 .rev()
2740 .map(|(ix, method)| {
2741 Button::new(
2742 SharedString::from(method.id.0.clone()),
2743 method.name.clone(),
2744 )
2745 .when(ix == 0, |el| {
2746 el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
2747 })
2748 .label_size(LabelSize::Small)
2749 .on_click({
2750 let method_id = method.id.clone();
2751 cx.listener(move |this, _, window, cx| {
2752 telemetry::event!(
2753 "Authenticate Agent Started",
2754 agent = this.agent.telemetry_id(),
2755 method = method_id
2756 );
2757
2758 this.authenticate(method_id.clone(), window, cx)
2759 })
2760 })
2761 }),
2762 ),
2763 )
2764 })
2765
2766 )
2767 }
2768
2769 fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
2770 let (message, action_slot) = match e {
2771 LoadError::NotInstalled {
2772 error_message,
2773 install_message,
2774 install_command,
2775 } => {
2776 let install_command = install_command.clone();
2777 let button = Button::new("install", install_message)
2778 .tooltip(Tooltip::text(install_command.clone()))
2779 .style(ButtonStyle::Outlined)
2780 .label_size(LabelSize::Small)
2781 .icon(IconName::Download)
2782 .icon_size(IconSize::Small)
2783 .icon_color(Color::Muted)
2784 .icon_position(IconPosition::Start)
2785 .on_click(cx.listener(move |this, _, window, cx| {
2786 telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id());
2787
2788 let task = this
2789 .workspace
2790 .update(cx, |workspace, cx| {
2791 let project = workspace.project().read(cx);
2792 let cwd = project.first_project_directory(cx);
2793 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2794 let spawn_in_terminal = task::SpawnInTerminal {
2795 id: task::TaskId(install_command.clone()),
2796 full_label: install_command.clone(),
2797 label: install_command.clone(),
2798 command: Some(install_command.clone()),
2799 args: Vec::new(),
2800 command_label: install_command.clone(),
2801 cwd,
2802 env: Default::default(),
2803 use_new_terminal: true,
2804 allow_concurrent_runs: true,
2805 reveal: Default::default(),
2806 reveal_target: Default::default(),
2807 hide: Default::default(),
2808 shell,
2809 show_summary: true,
2810 show_command: true,
2811 show_rerun: false,
2812 };
2813 workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
2814 })
2815 .ok();
2816 let Some(task) = task else { return };
2817 cx.spawn_in(window, async move |this, cx| {
2818 if let Some(Ok(_)) = task.await {
2819 this.update_in(cx, |this, window, cx| {
2820 this.reset(window, cx);
2821 })
2822 .ok();
2823 }
2824 })
2825 .detach()
2826 }));
2827
2828 (error_message.clone(), Some(button.into_any_element()))
2829 }
2830 LoadError::Unsupported {
2831 error_message,
2832 upgrade_message,
2833 upgrade_command,
2834 } => {
2835 let upgrade_command = upgrade_command.clone();
2836 let button = Button::new("upgrade", upgrade_message)
2837 .tooltip(Tooltip::text(upgrade_command.clone()))
2838 .style(ButtonStyle::Outlined)
2839 .label_size(LabelSize::Small)
2840 .icon(IconName::Download)
2841 .icon_size(IconSize::Small)
2842 .icon_color(Color::Muted)
2843 .icon_position(IconPosition::Start)
2844 .on_click(cx.listener(move |this, _, window, cx| {
2845 telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id());
2846
2847 let task = this
2848 .workspace
2849 .update(cx, |workspace, cx| {
2850 let project = workspace.project().read(cx);
2851 let cwd = project.first_project_directory(cx);
2852 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2853 let spawn_in_terminal = task::SpawnInTerminal {
2854 id: task::TaskId(upgrade_command.to_string()),
2855 full_label: upgrade_command.clone(),
2856 label: upgrade_command.clone(),
2857 command: Some(upgrade_command.clone()),
2858 args: Vec::new(),
2859 command_label: upgrade_command.clone(),
2860 cwd,
2861 env: Default::default(),
2862 use_new_terminal: true,
2863 allow_concurrent_runs: true,
2864 reveal: Default::default(),
2865 reveal_target: Default::default(),
2866 hide: Default::default(),
2867 shell,
2868 show_summary: true,
2869 show_command: true,
2870 show_rerun: false,
2871 };
2872 workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
2873 })
2874 .ok();
2875 let Some(task) = task else { return };
2876 cx.spawn_in(window, async move |this, cx| {
2877 if let Some(Ok(_)) = task.await {
2878 this.update_in(cx, |this, window, cx| {
2879 this.reset(window, cx);
2880 })
2881 .ok();
2882 }
2883 })
2884 .detach()
2885 }));
2886
2887 (error_message.clone(), Some(button.into_any_element()))
2888 }
2889 LoadError::Exited { .. } => ("Server exited with status {status}".into(), None),
2890 LoadError::Other(msg) => (
2891 msg.into(),
2892 Some(self.create_copy_button(msg.to_string()).into_any_element()),
2893 ),
2894 };
2895
2896 Callout::new()
2897 .severity(Severity::Error)
2898 .icon(IconName::XCircleFilled)
2899 .title("Failed to Launch")
2900 .description(message)
2901 .actions_slot(div().children(action_slot))
2902 .into_any_element()
2903 }
2904
2905 fn render_activity_bar(
2906 &self,
2907 thread_entity: &Entity<AcpThread>,
2908 window: &mut Window,
2909 cx: &Context<Self>,
2910 ) -> Option<AnyElement> {
2911 let thread = thread_entity.read(cx);
2912 let action_log = thread.action_log();
2913 let changed_buffers = action_log.read(cx).changed_buffers(cx);
2914 let plan = thread.plan();
2915
2916 if changed_buffers.is_empty() && plan.is_empty() {
2917 return None;
2918 }
2919
2920 let editor_bg_color = cx.theme().colors().editor_background;
2921 let active_color = cx.theme().colors().element_selected;
2922 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
2923
2924 let pending_edits = thread.has_pending_edit_tool_calls();
2925
2926 v_flex()
2927 .mt_1()
2928 .mx_2()
2929 .bg(bg_edit_files_disclosure)
2930 .border_1()
2931 .border_b_0()
2932 .border_color(cx.theme().colors().border)
2933 .rounded_t_md()
2934 .shadow(vec![gpui::BoxShadow {
2935 color: gpui::black().opacity(0.15),
2936 offset: point(px(1.), px(-1.)),
2937 blur_radius: px(3.),
2938 spread_radius: px(0.),
2939 }])
2940 .when(!plan.is_empty(), |this| {
2941 this.child(self.render_plan_summary(plan, window, cx))
2942 .when(self.plan_expanded, |parent| {
2943 parent.child(self.render_plan_entries(plan, window, cx))
2944 })
2945 })
2946 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2947 this.child(Divider::horizontal().color(DividerColor::Border))
2948 })
2949 .when(!changed_buffers.is_empty(), |this| {
2950 this.child(self.render_edits_summary(
2951 &changed_buffers,
2952 self.edits_expanded,
2953 pending_edits,
2954 window,
2955 cx,
2956 ))
2957 .when(self.edits_expanded, |parent| {
2958 parent.child(self.render_edited_files(
2959 action_log,
2960 &changed_buffers,
2961 pending_edits,
2962 cx,
2963 ))
2964 })
2965 })
2966 .into_any()
2967 .into()
2968 }
2969
2970 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2971 let stats = plan.stats();
2972
2973 let title = if let Some(entry) = stats.in_progress_entry
2974 && !self.plan_expanded
2975 {
2976 h_flex()
2977 .w_full()
2978 .cursor_default()
2979 .gap_1()
2980 .text_xs()
2981 .text_color(cx.theme().colors().text_muted)
2982 .justify_between()
2983 .child(
2984 h_flex()
2985 .gap_1()
2986 .child(
2987 Label::new("Current:")
2988 .size(LabelSize::Small)
2989 .color(Color::Muted),
2990 )
2991 .child(MarkdownElement::new(
2992 entry.content.clone(),
2993 plan_label_markdown_style(&entry.status, window, cx),
2994 )),
2995 )
2996 .when(stats.pending > 0, |this| {
2997 this.child(
2998 Label::new(format!("{} left", stats.pending))
2999 .size(LabelSize::Small)
3000 .color(Color::Muted)
3001 .mr_1(),
3002 )
3003 })
3004 } else {
3005 let status_label = if stats.pending == 0 {
3006 "All Done".to_string()
3007 } else if stats.completed == 0 {
3008 format!("{} Tasks", plan.entries.len())
3009 } else {
3010 format!("{}/{}", stats.completed, plan.entries.len())
3011 };
3012
3013 h_flex()
3014 .w_full()
3015 .gap_1()
3016 .justify_between()
3017 .child(
3018 Label::new("Plan")
3019 .size(LabelSize::Small)
3020 .color(Color::Muted),
3021 )
3022 .child(
3023 Label::new(status_label)
3024 .size(LabelSize::Small)
3025 .color(Color::Muted)
3026 .mr_1(),
3027 )
3028 };
3029
3030 h_flex()
3031 .p_1()
3032 .justify_between()
3033 .when(self.plan_expanded, |this| {
3034 this.border_b_1().border_color(cx.theme().colors().border)
3035 })
3036 .child(
3037 h_flex()
3038 .id("plan_summary")
3039 .w_full()
3040 .gap_1()
3041 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
3042 .child(title)
3043 .on_click(cx.listener(|this, _, _, cx| {
3044 this.plan_expanded = !this.plan_expanded;
3045 cx.notify();
3046 })),
3047 )
3048 }
3049
3050 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
3051 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
3052 let element = h_flex()
3053 .py_1()
3054 .px_2()
3055 .gap_2()
3056 .justify_between()
3057 .bg(cx.theme().colors().editor_background)
3058 .when(index < plan.entries.len() - 1, |parent| {
3059 parent.border_color(cx.theme().colors().border).border_b_1()
3060 })
3061 .child(
3062 h_flex()
3063 .id(("plan_entry", index))
3064 .gap_1p5()
3065 .max_w_full()
3066 .overflow_x_scroll()
3067 .text_xs()
3068 .text_color(cx.theme().colors().text_muted)
3069 .child(match entry.status {
3070 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
3071 .size(IconSize::Small)
3072 .color(Color::Muted)
3073 .into_any_element(),
3074 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
3075 .size(IconSize::Small)
3076 .color(Color::Accent)
3077 .with_animation(
3078 "running",
3079 Animation::new(Duration::from_secs(2)).repeat(),
3080 |icon, delta| {
3081 icon.transform(Transformation::rotate(percentage(delta)))
3082 },
3083 )
3084 .into_any_element(),
3085 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
3086 .size(IconSize::Small)
3087 .color(Color::Success)
3088 .into_any_element(),
3089 })
3090 .child(MarkdownElement::new(
3091 entry.content.clone(),
3092 plan_label_markdown_style(&entry.status, window, cx),
3093 )),
3094 );
3095
3096 Some(element)
3097 }))
3098 }
3099
3100 fn render_edits_summary(
3101 &self,
3102 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3103 expanded: bool,
3104 pending_edits: bool,
3105 window: &mut Window,
3106 cx: &Context<Self>,
3107 ) -> Div {
3108 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
3109
3110 let focus_handle = self.focus_handle(cx);
3111
3112 h_flex()
3113 .p_1()
3114 .justify_between()
3115 .flex_wrap()
3116 .when(expanded, |this| {
3117 this.border_b_1().border_color(cx.theme().colors().border)
3118 })
3119 .child(
3120 h_flex()
3121 .id("edits-container")
3122 .gap_1()
3123 .child(Disclosure::new("edits-disclosure", expanded))
3124 .map(|this| {
3125 if pending_edits {
3126 this.child(
3127 Label::new(format!(
3128 "Editing {} {}…",
3129 changed_buffers.len(),
3130 if changed_buffers.len() == 1 {
3131 "file"
3132 } else {
3133 "files"
3134 }
3135 ))
3136 .color(Color::Muted)
3137 .size(LabelSize::Small)
3138 .with_animation(
3139 "edit-label",
3140 Animation::new(Duration::from_secs(2))
3141 .repeat()
3142 .with_easing(pulsating_between(0.3, 0.7)),
3143 |label, delta| label.alpha(delta),
3144 ),
3145 )
3146 } else {
3147 this.child(
3148 Label::new("Edits")
3149 .size(LabelSize::Small)
3150 .color(Color::Muted),
3151 )
3152 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
3153 .child(
3154 Label::new(format!(
3155 "{} {}",
3156 changed_buffers.len(),
3157 if changed_buffers.len() == 1 {
3158 "file"
3159 } else {
3160 "files"
3161 }
3162 ))
3163 .size(LabelSize::Small)
3164 .color(Color::Muted),
3165 )
3166 }
3167 })
3168 .on_click(cx.listener(|this, _, _, cx| {
3169 this.edits_expanded = !this.edits_expanded;
3170 cx.notify();
3171 })),
3172 )
3173 .child(
3174 h_flex()
3175 .gap_1()
3176 .child(
3177 IconButton::new("review-changes", IconName::ListTodo)
3178 .icon_size(IconSize::Small)
3179 .tooltip({
3180 let focus_handle = focus_handle.clone();
3181 move |window, cx| {
3182 Tooltip::for_action_in(
3183 "Review Changes",
3184 &OpenAgentDiff,
3185 &focus_handle,
3186 window,
3187 cx,
3188 )
3189 }
3190 })
3191 .on_click(cx.listener(|_, _, window, cx| {
3192 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
3193 })),
3194 )
3195 .child(Divider::vertical().color(DividerColor::Border))
3196 .child(
3197 Button::new("reject-all-changes", "Reject All")
3198 .label_size(LabelSize::Small)
3199 .disabled(pending_edits)
3200 .when(pending_edits, |this| {
3201 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3202 })
3203 .key_binding(
3204 KeyBinding::for_action_in(
3205 &RejectAll,
3206 &focus_handle.clone(),
3207 window,
3208 cx,
3209 )
3210 .map(|kb| kb.size(rems_from_px(10.))),
3211 )
3212 .on_click(cx.listener(move |this, _, window, cx| {
3213 this.reject_all(&RejectAll, window, cx);
3214 })),
3215 )
3216 .child(
3217 Button::new("keep-all-changes", "Keep All")
3218 .label_size(LabelSize::Small)
3219 .disabled(pending_edits)
3220 .when(pending_edits, |this| {
3221 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3222 })
3223 .key_binding(
3224 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
3225 .map(|kb| kb.size(rems_from_px(10.))),
3226 )
3227 .on_click(cx.listener(move |this, _, window, cx| {
3228 this.keep_all(&KeepAll, window, cx);
3229 })),
3230 ),
3231 )
3232 }
3233
3234 fn render_edited_files(
3235 &self,
3236 action_log: &Entity<ActionLog>,
3237 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3238 pending_edits: bool,
3239 cx: &Context<Self>,
3240 ) -> Div {
3241 let editor_bg_color = cx.theme().colors().editor_background;
3242
3243 v_flex().children(changed_buffers.iter().enumerate().flat_map(
3244 |(index, (buffer, _diff))| {
3245 let file = buffer.read(cx).file()?;
3246 let path = file.path();
3247
3248 let file_path = path.parent().and_then(|parent| {
3249 let parent_str = parent.to_string_lossy();
3250
3251 if parent_str.is_empty() {
3252 None
3253 } else {
3254 Some(
3255 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
3256 .color(Color::Muted)
3257 .size(LabelSize::XSmall)
3258 .buffer_font(cx),
3259 )
3260 }
3261 });
3262
3263 let file_name = path.file_name().map(|name| {
3264 Label::new(name.to_string_lossy().to_string())
3265 .size(LabelSize::XSmall)
3266 .buffer_font(cx)
3267 });
3268
3269 let file_icon = FileIcons::get_icon(path, cx)
3270 .map(Icon::from_path)
3271 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
3272 .unwrap_or_else(|| {
3273 Icon::new(IconName::File)
3274 .color(Color::Muted)
3275 .size(IconSize::Small)
3276 });
3277
3278 let overlay_gradient = linear_gradient(
3279 90.,
3280 linear_color_stop(editor_bg_color, 1.),
3281 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
3282 );
3283
3284 let element = h_flex()
3285 .group("edited-code")
3286 .id(("file-container", index))
3287 .relative()
3288 .py_1()
3289 .pl_2()
3290 .pr_1()
3291 .gap_2()
3292 .justify_between()
3293 .bg(editor_bg_color)
3294 .when(index < changed_buffers.len() - 1, |parent| {
3295 parent.border_color(cx.theme().colors().border).border_b_1()
3296 })
3297 .child(
3298 h_flex()
3299 .id(("file-name", index))
3300 .pr_8()
3301 .gap_1p5()
3302 .max_w_full()
3303 .overflow_x_scroll()
3304 .child(file_icon)
3305 .child(h_flex().gap_0p5().children(file_name).children(file_path))
3306 .on_click({
3307 let buffer = buffer.clone();
3308 cx.listener(move |this, _, window, cx| {
3309 this.open_edited_buffer(&buffer, window, cx);
3310 })
3311 }),
3312 )
3313 .child(
3314 h_flex()
3315 .gap_1()
3316 .visible_on_hover("edited-code")
3317 .child(
3318 Button::new("review", "Review")
3319 .label_size(LabelSize::Small)
3320 .on_click({
3321 let buffer = buffer.clone();
3322 cx.listener(move |this, _, window, cx| {
3323 this.open_edited_buffer(&buffer, window, cx);
3324 })
3325 }),
3326 )
3327 .child(Divider::vertical().color(DividerColor::BorderVariant))
3328 .child(
3329 Button::new("reject-file", "Reject")
3330 .label_size(LabelSize::Small)
3331 .disabled(pending_edits)
3332 .on_click({
3333 let buffer = buffer.clone();
3334 let action_log = action_log.clone();
3335 move |_, _, cx| {
3336 action_log.update(cx, |action_log, cx| {
3337 action_log
3338 .reject_edits_in_ranges(
3339 buffer.clone(),
3340 vec![Anchor::MIN..Anchor::MAX],
3341 cx,
3342 )
3343 .detach_and_log_err(cx);
3344 })
3345 }
3346 }),
3347 )
3348 .child(
3349 Button::new("keep-file", "Keep")
3350 .label_size(LabelSize::Small)
3351 .disabled(pending_edits)
3352 .on_click({
3353 let buffer = buffer.clone();
3354 let action_log = action_log.clone();
3355 move |_, _, cx| {
3356 action_log.update(cx, |action_log, cx| {
3357 action_log.keep_edits_in_range(
3358 buffer.clone(),
3359 Anchor::MIN..Anchor::MAX,
3360 cx,
3361 );
3362 })
3363 }
3364 }),
3365 ),
3366 )
3367 .child(
3368 div()
3369 .id("gradient-overlay")
3370 .absolute()
3371 .h_full()
3372 .w_12()
3373 .top_0()
3374 .bottom_0()
3375 .right(px(152.))
3376 .bg(overlay_gradient),
3377 );
3378
3379 Some(element)
3380 },
3381 ))
3382 }
3383
3384 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
3385 let focus_handle = self.message_editor.focus_handle(cx);
3386 let editor_bg_color = cx.theme().colors().editor_background;
3387 let (expand_icon, expand_tooltip) = if self.editor_expanded {
3388 (IconName::Minimize, "Minimize Message Editor")
3389 } else {
3390 (IconName::Maximize, "Expand Message Editor")
3391 };
3392
3393 let backdrop = div()
3394 .size_full()
3395 .absolute()
3396 .inset_0()
3397 .bg(cx.theme().colors().panel_background)
3398 .opacity(0.8)
3399 .block_mouse_except_scroll();
3400
3401 let enable_editor = match self.thread_state {
3402 ThreadState::Loading { .. } | ThreadState::Ready { .. } => true,
3403 ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false,
3404 };
3405
3406 v_flex()
3407 .on_action(cx.listener(Self::expand_message_editor))
3408 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
3409 if let Some(profile_selector) = this.profile_selector.as_ref() {
3410 profile_selector.read(cx).menu_handle().toggle(window, cx);
3411 }
3412 }))
3413 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
3414 if let Some(model_selector) = this.model_selector.as_ref() {
3415 model_selector
3416 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
3417 }
3418 }))
3419 .p_2()
3420 .gap_2()
3421 .border_t_1()
3422 .border_color(cx.theme().colors().border)
3423 .bg(editor_bg_color)
3424 .when(self.editor_expanded, |this| {
3425 this.h(vh(0.8, window)).size_full().justify_between()
3426 })
3427 .child(
3428 v_flex()
3429 .relative()
3430 .size_full()
3431 .pt_1()
3432 .pr_2p5()
3433 .child(self.message_editor.clone())
3434 .child(
3435 h_flex()
3436 .absolute()
3437 .top_0()
3438 .right_0()
3439 .opacity(0.5)
3440 .hover(|this| this.opacity(1.0))
3441 .child(
3442 IconButton::new("toggle-height", expand_icon)
3443 .icon_size(IconSize::Small)
3444 .icon_color(Color::Muted)
3445 .tooltip({
3446 move |window, cx| {
3447 Tooltip::for_action_in(
3448 expand_tooltip,
3449 &ExpandMessageEditor,
3450 &focus_handle,
3451 window,
3452 cx,
3453 )
3454 }
3455 })
3456 .on_click(cx.listener(|_, _, window, cx| {
3457 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
3458 })),
3459 ),
3460 ),
3461 )
3462 .child(
3463 h_flex()
3464 .flex_none()
3465 .flex_wrap()
3466 .justify_between()
3467 .child(
3468 h_flex()
3469 .child(self.render_follow_toggle(cx))
3470 .children(self.render_burn_mode_toggle(cx)),
3471 )
3472 .child(
3473 h_flex()
3474 .gap_1()
3475 .children(self.render_token_usage(cx))
3476 .children(self.profile_selector.clone())
3477 .children(self.model_selector.clone())
3478 .child(self.render_send_button(cx)),
3479 ),
3480 )
3481 .when(!enable_editor, |this| this.child(backdrop))
3482 .into_any()
3483 }
3484
3485 pub(crate) fn as_native_connection(
3486 &self,
3487 cx: &App,
3488 ) -> Option<Rc<agent2::NativeAgentConnection>> {
3489 let acp_thread = self.thread()?.read(cx);
3490 acp_thread.connection().clone().downcast()
3491 }
3492
3493 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
3494 let acp_thread = self.thread()?.read(cx);
3495 self.as_native_connection(cx)?
3496 .thread(acp_thread.session_id(), cx)
3497 }
3498
3499 fn is_using_zed_ai_models(&self, cx: &App) -> bool {
3500 self.as_native_thread(cx)
3501 .and_then(|thread| thread.read(cx).model())
3502 .is_some_and(|model| model.provider_id() == language_model::ZED_CLOUD_PROVIDER_ID)
3503 }
3504
3505 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<Div> {
3506 let thread = self.thread()?.read(cx);
3507 let usage = thread.token_usage()?;
3508 let is_generating = thread.status() != ThreadStatus::Idle;
3509
3510 let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
3511 let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
3512
3513 Some(
3514 h_flex()
3515 .flex_shrink_0()
3516 .gap_0p5()
3517 .mr_1p5()
3518 .child(
3519 Label::new(used)
3520 .size(LabelSize::Small)
3521 .color(Color::Muted)
3522 .map(|label| {
3523 if is_generating {
3524 label
3525 .with_animation(
3526 "used-tokens-label",
3527 Animation::new(Duration::from_secs(2))
3528 .repeat()
3529 .with_easing(pulsating_between(0.3, 0.8)),
3530 |label, delta| label.alpha(delta),
3531 )
3532 .into_any()
3533 } else {
3534 label.into_any_element()
3535 }
3536 }),
3537 )
3538 .child(
3539 Label::new("/")
3540 .size(LabelSize::Small)
3541 .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
3542 )
3543 .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
3544 )
3545 }
3546
3547 fn toggle_burn_mode(
3548 &mut self,
3549 _: &ToggleBurnMode,
3550 _window: &mut Window,
3551 cx: &mut Context<Self>,
3552 ) {
3553 let Some(thread) = self.as_native_thread(cx) else {
3554 return;
3555 };
3556
3557 thread.update(cx, |thread, cx| {
3558 let current_mode = thread.completion_mode();
3559 thread.set_completion_mode(
3560 match current_mode {
3561 CompletionMode::Burn => CompletionMode::Normal,
3562 CompletionMode::Normal => CompletionMode::Burn,
3563 },
3564 cx,
3565 );
3566 });
3567 }
3568
3569 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
3570 let Some(thread) = self.thread() else {
3571 return;
3572 };
3573 let action_log = thread.read(cx).action_log().clone();
3574 action_log.update(cx, |action_log, cx| action_log.keep_all_edits(cx));
3575 }
3576
3577 fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
3578 let Some(thread) = self.thread() else {
3579 return;
3580 };
3581 let action_log = thread.read(cx).action_log().clone();
3582 action_log
3583 .update(cx, |action_log, cx| action_log.reject_all_edits(cx))
3584 .detach();
3585 }
3586
3587 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3588 let thread = self.as_native_thread(cx)?.read(cx);
3589
3590 if thread
3591 .model()
3592 .is_none_or(|model| !model.supports_burn_mode())
3593 {
3594 return None;
3595 }
3596
3597 let active_completion_mode = thread.completion_mode();
3598 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
3599 let icon = if burn_mode_enabled {
3600 IconName::ZedBurnModeOn
3601 } else {
3602 IconName::ZedBurnMode
3603 };
3604
3605 Some(
3606 IconButton::new("burn-mode", icon)
3607 .icon_size(IconSize::Small)
3608 .icon_color(Color::Muted)
3609 .toggle_state(burn_mode_enabled)
3610 .selected_icon_color(Color::Error)
3611 .on_click(cx.listener(|this, _event, window, cx| {
3612 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
3613 }))
3614 .tooltip(move |_window, cx| {
3615 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
3616 .into()
3617 })
3618 .into_any_element(),
3619 )
3620 }
3621
3622 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3623 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
3624 let is_generating = self
3625 .thread()
3626 .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
3627
3628 if self.is_loading_contents {
3629 div()
3630 .id("loading-message-content")
3631 .px_1()
3632 .tooltip(Tooltip::text("Loading Added Context…"))
3633 .child(loading_contents_spinner(IconSize::default()))
3634 .into_any_element()
3635 } else if is_generating && is_editor_empty {
3636 IconButton::new("stop-generation", IconName::Stop)
3637 .icon_color(Color::Error)
3638 .style(ButtonStyle::Tinted(ui::TintColor::Error))
3639 .tooltip(move |window, cx| {
3640 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
3641 })
3642 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3643 .into_any_element()
3644 } else {
3645 let send_btn_tooltip = if is_editor_empty && !is_generating {
3646 "Type to Send"
3647 } else if is_generating {
3648 "Stop and Send Message"
3649 } else {
3650 "Send"
3651 };
3652
3653 IconButton::new("send-message", IconName::Send)
3654 .style(ButtonStyle::Filled)
3655 .map(|this| {
3656 if is_editor_empty && !is_generating {
3657 this.disabled(true).icon_color(Color::Muted)
3658 } else {
3659 this.icon_color(Color::Accent)
3660 }
3661 })
3662 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
3663 .on_click(cx.listener(|this, _, window, cx| {
3664 this.send(window, cx);
3665 }))
3666 .into_any_element()
3667 }
3668 }
3669
3670 fn is_following(&self, cx: &App) -> bool {
3671 match self.thread().map(|thread| thread.read(cx).status()) {
3672 Some(ThreadStatus::Generating) => self
3673 .workspace
3674 .read_with(cx, |workspace, _| {
3675 workspace.is_being_followed(CollaboratorId::Agent)
3676 })
3677 .unwrap_or(false),
3678 _ => self.should_be_following,
3679 }
3680 }
3681
3682 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3683 let following = self.is_following(cx);
3684
3685 self.should_be_following = !following;
3686 if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) {
3687 self.workspace
3688 .update(cx, |workspace, cx| {
3689 if following {
3690 workspace.unfollow(CollaboratorId::Agent, window, cx);
3691 } else {
3692 workspace.follow(CollaboratorId::Agent, window, cx);
3693 }
3694 })
3695 .ok();
3696 }
3697
3698 telemetry::event!("Follow Agent Selected", following = !following);
3699 }
3700
3701 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
3702 let following = self.is_following(cx);
3703
3704 let tooltip_label = if following {
3705 if self.agent.name() == "Zed Agent" {
3706 format!("Stop Following the {}", self.agent.name())
3707 } else {
3708 format!("Stop Following {}", self.agent.name())
3709 }
3710 } else {
3711 if self.agent.name() == "Zed Agent" {
3712 format!("Follow the {}", self.agent.name())
3713 } else {
3714 format!("Follow {}", self.agent.name())
3715 }
3716 };
3717
3718 IconButton::new("follow-agent", IconName::Crosshair)
3719 .icon_size(IconSize::Small)
3720 .icon_color(Color::Muted)
3721 .toggle_state(following)
3722 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
3723 .tooltip(move |window, cx| {
3724 if following {
3725 Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx)
3726 } else {
3727 Tooltip::with_meta(
3728 tooltip_label.clone(),
3729 Some(&Follow),
3730 "Track the agent's location as it reads and edits files.",
3731 window,
3732 cx,
3733 )
3734 }
3735 })
3736 .on_click(cx.listener(move |this, _, window, cx| {
3737 this.toggle_following(window, cx);
3738 }))
3739 }
3740
3741 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
3742 let workspace = self.workspace.clone();
3743 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
3744 Self::open_link(text, &workspace, window, cx);
3745 })
3746 }
3747
3748 fn open_link(
3749 url: SharedString,
3750 workspace: &WeakEntity<Workspace>,
3751 window: &mut Window,
3752 cx: &mut App,
3753 ) {
3754 let Some(workspace) = workspace.upgrade() else {
3755 cx.open_url(&url);
3756 return;
3757 };
3758
3759 if let Some(mention) = MentionUri::parse(&url).log_err() {
3760 workspace.update(cx, |workspace, cx| match mention {
3761 MentionUri::File { abs_path } => {
3762 let project = workspace.project();
3763 let Some(path) =
3764 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
3765 else {
3766 return;
3767 };
3768
3769 workspace
3770 .open_path(path, None, true, window, cx)
3771 .detach_and_log_err(cx);
3772 }
3773 MentionUri::PastedImage => {}
3774 MentionUri::Directory { abs_path } => {
3775 let project = workspace.project();
3776 let Some(entry) = project.update(cx, |project, cx| {
3777 let path = project.find_project_path(abs_path, cx)?;
3778 project.entry_for_path(&path, cx)
3779 }) else {
3780 return;
3781 };
3782
3783 project.update(cx, |_, cx| {
3784 cx.emit(project::Event::RevealInProjectPanel(entry.id));
3785 });
3786 }
3787 MentionUri::Symbol {
3788 abs_path: path,
3789 line_range,
3790 ..
3791 }
3792 | MentionUri::Selection {
3793 abs_path: Some(path),
3794 line_range,
3795 } => {
3796 let project = workspace.project();
3797 let Some((path, _)) = project.update(cx, |project, cx| {
3798 let path = project.find_project_path(path, cx)?;
3799 let entry = project.entry_for_path(&path, cx)?;
3800 Some((path, entry))
3801 }) else {
3802 return;
3803 };
3804
3805 let item = workspace.open_path(path, None, true, window, cx);
3806 window
3807 .spawn(cx, async move |cx| {
3808 let Some(editor) = item.await?.downcast::<Editor>() else {
3809 return Ok(());
3810 };
3811 let range = Point::new(*line_range.start(), 0)
3812 ..Point::new(*line_range.start(), 0);
3813 editor
3814 .update_in(cx, |editor, window, cx| {
3815 editor.change_selections(
3816 SelectionEffects::scroll(Autoscroll::center()),
3817 window,
3818 cx,
3819 |s| s.select_ranges(vec![range]),
3820 );
3821 })
3822 .ok();
3823 anyhow::Ok(())
3824 })
3825 .detach_and_log_err(cx);
3826 }
3827 MentionUri::Selection { abs_path: None, .. } => {}
3828 MentionUri::Thread { id, name } => {
3829 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3830 panel.update(cx, |panel, cx| {
3831 panel.load_agent_thread(
3832 DbThreadMetadata {
3833 id,
3834 title: name.into(),
3835 updated_at: Default::default(),
3836 },
3837 window,
3838 cx,
3839 )
3840 });
3841 }
3842 }
3843 MentionUri::TextThread { path, .. } => {
3844 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3845 panel.update(cx, |panel, cx| {
3846 panel
3847 .open_saved_prompt_editor(path.as_path().into(), window, cx)
3848 .detach_and_log_err(cx);
3849 });
3850 }
3851 }
3852 MentionUri::Rule { id, .. } => {
3853 let PromptId::User { uuid } = id else {
3854 return;
3855 };
3856 window.dispatch_action(
3857 Box::new(OpenRulesLibrary {
3858 prompt_to_select: Some(uuid.0),
3859 }),
3860 cx,
3861 )
3862 }
3863 MentionUri::Fetch { url } => {
3864 cx.open_url(url.as_str());
3865 }
3866 })
3867 } else {
3868 cx.open_url(&url);
3869 }
3870 }
3871
3872 fn open_tool_call_location(
3873 &self,
3874 entry_ix: usize,
3875 location_ix: usize,
3876 window: &mut Window,
3877 cx: &mut Context<Self>,
3878 ) -> Option<()> {
3879 let (tool_call_location, agent_location) = self
3880 .thread()?
3881 .read(cx)
3882 .entries()
3883 .get(entry_ix)?
3884 .location(location_ix)?;
3885
3886 let project_path = self
3887 .project
3888 .read(cx)
3889 .find_project_path(&tool_call_location.path, cx)?;
3890
3891 let open_task = self
3892 .workspace
3893 .update(cx, |workspace, cx| {
3894 workspace.open_path(project_path, None, true, window, cx)
3895 })
3896 .log_err()?;
3897 window
3898 .spawn(cx, async move |cx| {
3899 let item = open_task.await?;
3900
3901 let Some(active_editor) = item.downcast::<Editor>() else {
3902 return anyhow::Ok(());
3903 };
3904
3905 active_editor.update_in(cx, |editor, window, cx| {
3906 let multibuffer = editor.buffer().read(cx);
3907 let buffer = multibuffer.as_singleton();
3908 if agent_location.buffer.upgrade() == buffer {
3909 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
3910 let anchor = editor::Anchor::in_buffer(
3911 excerpt_id.unwrap(),
3912 buffer.unwrap().read(cx).remote_id(),
3913 agent_location.position,
3914 );
3915 editor.change_selections(Default::default(), window, cx, |selections| {
3916 selections.select_anchor_ranges([anchor..anchor]);
3917 })
3918 } else {
3919 let row = tool_call_location.line.unwrap_or_default();
3920 editor.change_selections(Default::default(), window, cx, |selections| {
3921 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
3922 })
3923 }
3924 })?;
3925
3926 anyhow::Ok(())
3927 })
3928 .detach_and_log_err(cx);
3929
3930 None
3931 }
3932
3933 pub fn open_thread_as_markdown(
3934 &self,
3935 workspace: Entity<Workspace>,
3936 window: &mut Window,
3937 cx: &mut App,
3938 ) -> Task<anyhow::Result<()>> {
3939 let markdown_language_task = workspace
3940 .read(cx)
3941 .app_state()
3942 .languages
3943 .language_for_name("Markdown");
3944
3945 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
3946 let thread = thread.read(cx);
3947 (thread.title().to_string(), thread.to_markdown(cx))
3948 } else {
3949 return Task::ready(Ok(()));
3950 };
3951
3952 window.spawn(cx, async move |cx| {
3953 let markdown_language = markdown_language_task.await?;
3954
3955 workspace.update_in(cx, |workspace, window, cx| {
3956 let project = workspace.project().clone();
3957
3958 if !project.read(cx).is_local() {
3959 bail!("failed to open active thread as markdown in remote project");
3960 }
3961
3962 let buffer = project.update(cx, |project, cx| {
3963 project.create_local_buffer(&markdown, Some(markdown_language), cx)
3964 });
3965 let buffer = cx.new(|cx| {
3966 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
3967 });
3968
3969 workspace.add_item_to_active_pane(
3970 Box::new(cx.new(|cx| {
3971 let mut editor =
3972 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
3973 editor.set_breadcrumb_header(thread_summary);
3974 editor
3975 })),
3976 None,
3977 true,
3978 window,
3979 cx,
3980 );
3981
3982 anyhow::Ok(())
3983 })??;
3984 anyhow::Ok(())
3985 })
3986 }
3987
3988 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
3989 self.list_state.scroll_to(ListOffset::default());
3990 cx.notify();
3991 }
3992
3993 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
3994 if let Some(thread) = self.thread() {
3995 let entry_count = thread.read(cx).entries().len();
3996 self.list_state.reset(entry_count);
3997 cx.notify();
3998 }
3999 }
4000
4001 fn notify_with_sound(
4002 &mut self,
4003 caption: impl Into<SharedString>,
4004 icon: IconName,
4005 window: &mut Window,
4006 cx: &mut Context<Self>,
4007 ) {
4008 self.play_notification_sound(window, cx);
4009 self.show_notification(caption, icon, window, cx);
4010 }
4011
4012 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
4013 let settings = AgentSettings::get_global(cx);
4014 if settings.play_sound_when_agent_done && !window.is_window_active() {
4015 Audio::play_sound(Sound::AgentDone, cx);
4016 }
4017 }
4018
4019 fn show_notification(
4020 &mut self,
4021 caption: impl Into<SharedString>,
4022 icon: IconName,
4023 window: &mut Window,
4024 cx: &mut Context<Self>,
4025 ) {
4026 if window.is_window_active() || !self.notifications.is_empty() {
4027 return;
4028 }
4029
4030 // TODO: Change this once we have title summarization for external agents.
4031 let title = self.agent.name();
4032
4033 match AgentSettings::get_global(cx).notify_when_agent_waiting {
4034 NotifyWhenAgentWaiting::PrimaryScreen => {
4035 if let Some(primary) = cx.primary_display() {
4036 self.pop_up(icon, caption.into(), title, window, primary, cx);
4037 }
4038 }
4039 NotifyWhenAgentWaiting::AllScreens => {
4040 let caption = caption.into();
4041 for screen in cx.displays() {
4042 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
4043 }
4044 }
4045 NotifyWhenAgentWaiting::Never => {
4046 // Don't show anything
4047 }
4048 }
4049 }
4050
4051 fn pop_up(
4052 &mut self,
4053 icon: IconName,
4054 caption: SharedString,
4055 title: SharedString,
4056 window: &mut Window,
4057 screen: Rc<dyn PlatformDisplay>,
4058 cx: &mut Context<Self>,
4059 ) {
4060 let options = AgentNotification::window_options(screen, cx);
4061
4062 let project_name = self.workspace.upgrade().and_then(|workspace| {
4063 workspace
4064 .read(cx)
4065 .project()
4066 .read(cx)
4067 .visible_worktrees(cx)
4068 .next()
4069 .map(|worktree| worktree.read(cx).root_name().to_string())
4070 });
4071
4072 if let Some(screen_window) = cx
4073 .open_window(options, |_, cx| {
4074 cx.new(|_| {
4075 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
4076 })
4077 })
4078 .log_err()
4079 && let Some(pop_up) = screen_window.entity(cx).log_err()
4080 {
4081 self.notification_subscriptions
4082 .entry(screen_window)
4083 .or_insert_with(Vec::new)
4084 .push(cx.subscribe_in(&pop_up, window, {
4085 |this, _, event, window, cx| match event {
4086 AgentNotificationEvent::Accepted => {
4087 let handle = window.window_handle();
4088 cx.activate(true);
4089
4090 let workspace_handle = this.workspace.clone();
4091
4092 // If there are multiple Zed windows, activate the correct one.
4093 cx.defer(move |cx| {
4094 handle
4095 .update(cx, |_view, window, _cx| {
4096 window.activate_window();
4097
4098 if let Some(workspace) = workspace_handle.upgrade() {
4099 workspace.update(_cx, |workspace, cx| {
4100 workspace.focus_panel::<AgentPanel>(window, cx);
4101 });
4102 }
4103 })
4104 .log_err();
4105 });
4106
4107 this.dismiss_notifications(cx);
4108 }
4109 AgentNotificationEvent::Dismissed => {
4110 this.dismiss_notifications(cx);
4111 }
4112 }
4113 }));
4114
4115 self.notifications.push(screen_window);
4116
4117 // If the user manually refocuses the original window, dismiss the popup.
4118 self.notification_subscriptions
4119 .entry(screen_window)
4120 .or_insert_with(Vec::new)
4121 .push({
4122 let pop_up_weak = pop_up.downgrade();
4123
4124 cx.observe_window_activation(window, move |_, window, cx| {
4125 if window.is_window_active()
4126 && let Some(pop_up) = pop_up_weak.upgrade()
4127 {
4128 pop_up.update(cx, |_, cx| {
4129 cx.emit(AgentNotificationEvent::Dismissed);
4130 });
4131 }
4132 })
4133 });
4134 }
4135 }
4136
4137 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
4138 for window in self.notifications.drain(..) {
4139 window
4140 .update(cx, |_, window, _| {
4141 window.remove_window();
4142 })
4143 .ok();
4144
4145 self.notification_subscriptions.remove(&window);
4146 }
4147 }
4148
4149 fn render_thread_controls(
4150 &self,
4151 thread: &Entity<AcpThread>,
4152 cx: &Context<Self>,
4153 ) -> impl IntoElement {
4154 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
4155 if is_generating {
4156 return h_flex().id("thread-controls-container").ml_1().child(
4157 div()
4158 .py_2()
4159 .px(rems_from_px(22.))
4160 .child(SpinnerLabel::new().size(LabelSize::Small)),
4161 );
4162 }
4163 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
4164 .shape(ui::IconButtonShape::Square)
4165 .icon_size(IconSize::Small)
4166 .icon_color(Color::Ignored)
4167 .tooltip(Tooltip::text("Open Thread as Markdown"))
4168 .on_click(cx.listener(move |this, _, window, cx| {
4169 if let Some(workspace) = this.workspace.upgrade() {
4170 this.open_thread_as_markdown(workspace, window, cx)
4171 .detach_and_log_err(cx);
4172 }
4173 }));
4174
4175 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
4176 .shape(ui::IconButtonShape::Square)
4177 .icon_size(IconSize::Small)
4178 .icon_color(Color::Ignored)
4179 .tooltip(Tooltip::text("Scroll To Top"))
4180 .on_click(cx.listener(move |this, _, _, cx| {
4181 this.scroll_to_top(cx);
4182 }));
4183
4184 let mut container = h_flex()
4185 .id("thread-controls-container")
4186 .group("thread-controls-container")
4187 .w_full()
4188 .mr_1()
4189 .pt_1()
4190 .pb_2()
4191 .px(RESPONSE_PADDING_X)
4192 .gap_px()
4193 .opacity(0.4)
4194 .hover(|style| style.opacity(1.))
4195 .flex_wrap()
4196 .justify_end();
4197
4198 if AgentSettings::get_global(cx).enable_feedback
4199 && self
4200 .thread()
4201 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
4202 {
4203 let feedback = self.thread_feedback.feedback;
4204 container = container.child(
4205 div().visible_on_hover("thread-controls-container").child(
4206 Label::new(
4207 match feedback {
4208 Some(ThreadFeedback::Positive) => "Thanks for your feedback!",
4209 Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.",
4210 None => "Rating the thread sends all of your current conversation to the Zed team.",
4211 }
4212 )
4213 .color(Color::Muted)
4214 .size(LabelSize::XSmall)
4215 .truncate(),
4216 ),
4217 ).child(
4218 h_flex()
4219 .child(
4220 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
4221 .shape(ui::IconButtonShape::Square)
4222 .icon_size(IconSize::Small)
4223 .icon_color(match feedback {
4224 Some(ThreadFeedback::Positive) => Color::Accent,
4225 _ => Color::Ignored,
4226 })
4227 .tooltip(Tooltip::text("Helpful Response"))
4228 .on_click(cx.listener(move |this, _, window, cx| {
4229 this.handle_feedback_click(
4230 ThreadFeedback::Positive,
4231 window,
4232 cx,
4233 );
4234 })),
4235 )
4236 .child(
4237 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
4238 .shape(ui::IconButtonShape::Square)
4239 .icon_size(IconSize::Small)
4240 .icon_color(match feedback {
4241 Some(ThreadFeedback::Negative) => Color::Accent,
4242 _ => Color::Ignored,
4243 })
4244 .tooltip(Tooltip::text("Not Helpful"))
4245 .on_click(cx.listener(move |this, _, window, cx| {
4246 this.handle_feedback_click(
4247 ThreadFeedback::Negative,
4248 window,
4249 cx,
4250 );
4251 })),
4252 )
4253 )
4254 }
4255
4256 container.child(open_as_markdown).child(scroll_to_top)
4257 }
4258
4259 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
4260 h_flex()
4261 .key_context("AgentFeedbackMessageEditor")
4262 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
4263 this.thread_feedback.dismiss_comments();
4264 cx.notify();
4265 }))
4266 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
4267 this.submit_feedback_message(cx);
4268 }))
4269 .p_2()
4270 .mb_2()
4271 .mx_5()
4272 .gap_1()
4273 .rounded_md()
4274 .border_1()
4275 .border_color(cx.theme().colors().border)
4276 .bg(cx.theme().colors().editor_background)
4277 .child(div().w_full().child(editor))
4278 .child(
4279 h_flex()
4280 .child(
4281 IconButton::new("dismiss-feedback-message", IconName::Close)
4282 .icon_color(Color::Error)
4283 .icon_size(IconSize::XSmall)
4284 .shape(ui::IconButtonShape::Square)
4285 .on_click(cx.listener(move |this, _, _window, cx| {
4286 this.thread_feedback.dismiss_comments();
4287 cx.notify();
4288 })),
4289 )
4290 .child(
4291 IconButton::new("submit-feedback-message", IconName::Return)
4292 .icon_size(IconSize::XSmall)
4293 .shape(ui::IconButtonShape::Square)
4294 .on_click(cx.listener(move |this, _, _window, cx| {
4295 this.submit_feedback_message(cx);
4296 })),
4297 ),
4298 )
4299 }
4300
4301 fn handle_feedback_click(
4302 &mut self,
4303 feedback: ThreadFeedback,
4304 window: &mut Window,
4305 cx: &mut Context<Self>,
4306 ) {
4307 let Some(thread) = self.thread().cloned() else {
4308 return;
4309 };
4310
4311 self.thread_feedback.submit(thread, feedback, window, cx);
4312 cx.notify();
4313 }
4314
4315 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4316 let Some(thread) = self.thread().cloned() else {
4317 return;
4318 };
4319
4320 self.thread_feedback.submit_comments(thread, cx);
4321 cx.notify();
4322 }
4323
4324 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
4325 div()
4326 .id("acp-thread-scrollbar")
4327 .occlude()
4328 .on_mouse_move(cx.listener(|_, _, _, cx| {
4329 cx.notify();
4330 cx.stop_propagation()
4331 }))
4332 .on_hover(|_, _, cx| {
4333 cx.stop_propagation();
4334 })
4335 .on_any_mouse_down(|_, _, cx| {
4336 cx.stop_propagation();
4337 })
4338 .on_mouse_up(
4339 MouseButton::Left,
4340 cx.listener(|_, _, _, cx| {
4341 cx.stop_propagation();
4342 }),
4343 )
4344 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4345 cx.notify();
4346 }))
4347 .h_full()
4348 .absolute()
4349 .right_1()
4350 .top_1()
4351 .bottom_0()
4352 .w(px(12.))
4353 .cursor_default()
4354 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
4355 }
4356
4357 fn render_token_limit_callout(
4358 &self,
4359 line_height: Pixels,
4360 cx: &mut Context<Self>,
4361 ) -> Option<Callout> {
4362 let token_usage = self.thread()?.read(cx).token_usage()?;
4363 let ratio = token_usage.ratio();
4364
4365 let (severity, title) = match ratio {
4366 acp_thread::TokenUsageRatio::Normal => return None,
4367 acp_thread::TokenUsageRatio::Warning => {
4368 (Severity::Warning, "Thread reaching the token limit soon")
4369 }
4370 acp_thread::TokenUsageRatio::Exceeded => {
4371 (Severity::Error, "Thread reached the token limit")
4372 }
4373 };
4374
4375 let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
4376 thread.read(cx).completion_mode() == CompletionMode::Normal
4377 && thread
4378 .read(cx)
4379 .model()
4380 .is_some_and(|model| model.supports_burn_mode())
4381 });
4382
4383 let description = if burn_mode_available {
4384 "To continue, start a new thread from a summary or turn Burn Mode on."
4385 } else {
4386 "To continue, start a new thread from a summary."
4387 };
4388
4389 Some(
4390 Callout::new()
4391 .severity(severity)
4392 .line_height(line_height)
4393 .title(title)
4394 .description(description)
4395 .actions_slot(
4396 h_flex()
4397 .gap_0p5()
4398 .child(
4399 Button::new("start-new-thread", "Start New Thread")
4400 .label_size(LabelSize::Small)
4401 .on_click(cx.listener(|this, _, window, cx| {
4402 let Some(thread) = this.thread() else {
4403 return;
4404 };
4405 let session_id = thread.read(cx).session_id().clone();
4406 window.dispatch_action(
4407 crate::NewNativeAgentThreadFromSummary {
4408 from_session_id: session_id,
4409 }
4410 .boxed_clone(),
4411 cx,
4412 );
4413 })),
4414 )
4415 .when(burn_mode_available, |this| {
4416 this.child(
4417 IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
4418 .icon_size(IconSize::XSmall)
4419 .on_click(cx.listener(|this, _event, window, cx| {
4420 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4421 })),
4422 )
4423 }),
4424 ),
4425 )
4426 }
4427
4428 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
4429 if !self.is_using_zed_ai_models(cx) {
4430 return None;
4431 }
4432
4433 let user_store = self.project.read(cx).user_store().read(cx);
4434 if user_store.is_usage_based_billing_enabled() {
4435 return None;
4436 }
4437
4438 let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
4439
4440 let usage = user_store.model_request_usage()?;
4441
4442 Some(
4443 div()
4444 .child(UsageCallout::new(plan, usage))
4445 .line_height(line_height),
4446 )
4447 }
4448
4449 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4450 self.entry_view_state.update(cx, |entry_view_state, cx| {
4451 entry_view_state.settings_changed(cx);
4452 });
4453 }
4454
4455 pub(crate) fn insert_dragged_files(
4456 &self,
4457 paths: Vec<project::ProjectPath>,
4458 added_worktrees: Vec<Entity<project::Worktree>>,
4459 window: &mut Window,
4460 cx: &mut Context<Self>,
4461 ) {
4462 self.message_editor.update(cx, |message_editor, cx| {
4463 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
4464 })
4465 }
4466
4467 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
4468 self.message_editor.update(cx, |message_editor, cx| {
4469 message_editor.insert_selections(window, cx);
4470 })
4471 }
4472
4473 fn render_thread_retry_status_callout(
4474 &self,
4475 _window: &mut Window,
4476 _cx: &mut Context<Self>,
4477 ) -> Option<Callout> {
4478 let state = self.thread_retry_status.as_ref()?;
4479
4480 let next_attempt_in = state
4481 .duration
4482 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
4483 if next_attempt_in.is_zero() {
4484 return None;
4485 }
4486
4487 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
4488
4489 let retry_message = if state.max_attempts == 1 {
4490 if next_attempt_in_secs == 1 {
4491 "Retrying. Next attempt in 1 second.".to_string()
4492 } else {
4493 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
4494 }
4495 } else if next_attempt_in_secs == 1 {
4496 format!(
4497 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
4498 state.attempt, state.max_attempts,
4499 )
4500 } else {
4501 format!(
4502 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
4503 state.attempt, state.max_attempts,
4504 )
4505 };
4506
4507 Some(
4508 Callout::new()
4509 .severity(Severity::Warning)
4510 .title(state.last_error.clone())
4511 .description(retry_message),
4512 )
4513 }
4514
4515 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
4516 let content = match self.thread_error.as_ref()? {
4517 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
4518 ThreadError::AuthenticationRequired(error) => {
4519 self.render_authentication_required_error(error.clone(), cx)
4520 }
4521 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
4522 ThreadError::ModelRequestLimitReached(plan) => {
4523 self.render_model_request_limit_reached_error(*plan, cx)
4524 }
4525 ThreadError::ToolUseLimitReached => {
4526 self.render_tool_use_limit_reached_error(window, cx)?
4527 }
4528 };
4529
4530 Some(div().child(content))
4531 }
4532
4533 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
4534 let can_resume = self
4535 .thread()
4536 .map_or(false, |thread| thread.read(cx).can_resume(cx));
4537
4538 let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| {
4539 let thread = thread.read(cx);
4540 let supports_burn_mode = thread
4541 .model()
4542 .map_or(false, |model| model.supports_burn_mode());
4543 supports_burn_mode && thread.completion_mode() == CompletionMode::Normal
4544 });
4545
4546 Callout::new()
4547 .severity(Severity::Error)
4548 .title("Error")
4549 .icon(IconName::XCircle)
4550 .description(error.clone())
4551 .actions_slot(
4552 h_flex()
4553 .gap_0p5()
4554 .when(can_resume && can_enable_burn_mode, |this| {
4555 this.child(
4556 Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry")
4557 .icon(IconName::ZedBurnMode)
4558 .icon_position(IconPosition::Start)
4559 .icon_size(IconSize::Small)
4560 .label_size(LabelSize::Small)
4561 .on_click(cx.listener(|this, _, window, cx| {
4562 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4563 this.resume_chat(cx);
4564 })),
4565 )
4566 })
4567 .when(can_resume, |this| {
4568 this.child(
4569 Button::new("retry", "Retry")
4570 .icon(IconName::RotateCw)
4571 .icon_position(IconPosition::Start)
4572 .icon_size(IconSize::Small)
4573 .label_size(LabelSize::Small)
4574 .on_click(cx.listener(|this, _, _window, cx| {
4575 this.resume_chat(cx);
4576 })),
4577 )
4578 })
4579 .child(self.create_copy_button(error.to_string())),
4580 )
4581 .dismiss_action(self.dismiss_error_button(cx))
4582 }
4583
4584 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
4585 const ERROR_MESSAGE: &str =
4586 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
4587
4588 Callout::new()
4589 .severity(Severity::Error)
4590 .icon(IconName::XCircle)
4591 .title("Free Usage Exceeded")
4592 .description(ERROR_MESSAGE)
4593 .actions_slot(
4594 h_flex()
4595 .gap_0p5()
4596 .child(self.upgrade_button(cx))
4597 .child(self.create_copy_button(ERROR_MESSAGE)),
4598 )
4599 .dismiss_action(self.dismiss_error_button(cx))
4600 }
4601
4602 fn render_authentication_required_error(
4603 &self,
4604 error: SharedString,
4605 cx: &mut Context<Self>,
4606 ) -> Callout {
4607 Callout::new()
4608 .severity(Severity::Error)
4609 .title("Authentication Required")
4610 .icon(IconName::XCircle)
4611 .description(error.clone())
4612 .actions_slot(
4613 h_flex()
4614 .gap_0p5()
4615 .child(self.authenticate_button(cx))
4616 .child(self.create_copy_button(error)),
4617 )
4618 .dismiss_action(self.dismiss_error_button(cx))
4619 }
4620
4621 fn render_model_request_limit_reached_error(
4622 &self,
4623 plan: cloud_llm_client::Plan,
4624 cx: &mut Context<Self>,
4625 ) -> Callout {
4626 let error_message = match plan {
4627 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
4628 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
4629 "Upgrade to Zed Pro for more prompts."
4630 }
4631 };
4632
4633 Callout::new()
4634 .severity(Severity::Error)
4635 .title("Model Prompt Limit Reached")
4636 .icon(IconName::XCircle)
4637 .description(error_message)
4638 .actions_slot(
4639 h_flex()
4640 .gap_0p5()
4641 .child(self.upgrade_button(cx))
4642 .child(self.create_copy_button(error_message)),
4643 )
4644 .dismiss_action(self.dismiss_error_button(cx))
4645 }
4646
4647 fn render_tool_use_limit_reached_error(
4648 &self,
4649 window: &mut Window,
4650 cx: &mut Context<Self>,
4651 ) -> Option<Callout> {
4652 let thread = self.as_native_thread(cx)?;
4653 let supports_burn_mode = thread
4654 .read(cx)
4655 .model()
4656 .is_some_and(|model| model.supports_burn_mode());
4657
4658 let focus_handle = self.focus_handle(cx);
4659
4660 Some(
4661 Callout::new()
4662 .icon(IconName::Info)
4663 .title("Consecutive tool use limit reached.")
4664 .actions_slot(
4665 h_flex()
4666 .gap_0p5()
4667 .when(supports_burn_mode, |this| {
4668 this.child(
4669 Button::new("continue-burn-mode", "Continue with Burn Mode")
4670 .style(ButtonStyle::Filled)
4671 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4672 .layer(ElevationIndex::ModalSurface)
4673 .label_size(LabelSize::Small)
4674 .key_binding(
4675 KeyBinding::for_action_in(
4676 &ContinueWithBurnMode,
4677 &focus_handle,
4678 window,
4679 cx,
4680 )
4681 .map(|kb| kb.size(rems_from_px(10.))),
4682 )
4683 .tooltip(Tooltip::text(
4684 "Enable Burn Mode for unlimited tool use.",
4685 ))
4686 .on_click({
4687 cx.listener(move |this, _, _window, cx| {
4688 thread.update(cx, |thread, cx| {
4689 thread
4690 .set_completion_mode(CompletionMode::Burn, cx);
4691 });
4692 this.resume_chat(cx);
4693 })
4694 }),
4695 )
4696 })
4697 .child(
4698 Button::new("continue-conversation", "Continue")
4699 .layer(ElevationIndex::ModalSurface)
4700 .label_size(LabelSize::Small)
4701 .key_binding(
4702 KeyBinding::for_action_in(
4703 &ContinueThread,
4704 &focus_handle,
4705 window,
4706 cx,
4707 )
4708 .map(|kb| kb.size(rems_from_px(10.))),
4709 )
4710 .on_click(cx.listener(|this, _, _window, cx| {
4711 this.resume_chat(cx);
4712 })),
4713 ),
4714 ),
4715 )
4716 }
4717
4718 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
4719 let message = message.into();
4720
4721 IconButton::new("copy", IconName::Copy)
4722 .icon_size(IconSize::Small)
4723 .icon_color(Color::Muted)
4724 .tooltip(Tooltip::text("Copy Error Message"))
4725 .on_click(move |_, _, cx| {
4726 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
4727 })
4728 }
4729
4730 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4731 IconButton::new("dismiss", IconName::Close)
4732 .icon_size(IconSize::Small)
4733 .icon_color(Color::Muted)
4734 .tooltip(Tooltip::text("Dismiss Error"))
4735 .on_click(cx.listener({
4736 move |this, _, _, cx| {
4737 this.clear_thread_error(cx);
4738 cx.notify();
4739 }
4740 }))
4741 }
4742
4743 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4744 Button::new("authenticate", "Authenticate")
4745 .label_size(LabelSize::Small)
4746 .style(ButtonStyle::Filled)
4747 .on_click(cx.listener({
4748 move |this, _, window, cx| {
4749 let agent = this.agent.clone();
4750 let ThreadState::Ready { thread, .. } = &this.thread_state else {
4751 return;
4752 };
4753
4754 let connection = thread.read(cx).connection().clone();
4755 let err = AuthRequired {
4756 description: None,
4757 provider_id: None,
4758 };
4759 this.clear_thread_error(cx);
4760 let this = cx.weak_entity();
4761 window.defer(cx, |window, cx| {
4762 Self::handle_auth_required(this, err, agent, connection, window, cx);
4763 })
4764 }
4765 }))
4766 }
4767
4768 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4769 let agent = self.agent.clone();
4770 let ThreadState::Ready { thread, .. } = &self.thread_state else {
4771 return;
4772 };
4773
4774 let connection = thread.read(cx).connection().clone();
4775 let err = AuthRequired {
4776 description: None,
4777 provider_id: None,
4778 };
4779 self.clear_thread_error(cx);
4780 let this = cx.weak_entity();
4781 window.defer(cx, |window, cx| {
4782 Self::handle_auth_required(this, err, agent, connection, window, cx);
4783 })
4784 }
4785
4786 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4787 Button::new("upgrade", "Upgrade")
4788 .label_size(LabelSize::Small)
4789 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4790 .on_click(cx.listener({
4791 move |this, _, _, cx| {
4792 this.clear_thread_error(cx);
4793 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
4794 }
4795 }))
4796 }
4797
4798 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4799 self.thread_state = Self::initial_state(
4800 self.agent.clone(),
4801 None,
4802 self.workspace.clone(),
4803 self.project.clone(),
4804 window,
4805 cx,
4806 );
4807 cx.notify();
4808 }
4809
4810 pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
4811 let task = match entry {
4812 HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
4813 history.delete_thread(thread.id.clone(), cx)
4814 }),
4815 HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| {
4816 history.delete_text_thread(context.path.clone(), cx)
4817 }),
4818 };
4819 task.detach_and_log_err(cx);
4820 }
4821}
4822
4823fn loading_contents_spinner(size: IconSize) -> AnyElement {
4824 Icon::new(IconName::LoadCircle)
4825 .size(size)
4826 .color(Color::Accent)
4827 .with_animation(
4828 "load_context_circle",
4829 Animation::new(Duration::from_secs(3)).repeat(),
4830 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
4831 )
4832 .into_any_element()
4833}
4834
4835impl Focusable for AcpThreadView {
4836 fn focus_handle(&self, cx: &App) -> FocusHandle {
4837 match self.thread_state {
4838 ThreadState::Loading { .. } | ThreadState::Ready { .. } => {
4839 self.message_editor.focus_handle(cx)
4840 }
4841 ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => {
4842 self.focus_handle.clone()
4843 }
4844 }
4845 }
4846}
4847
4848impl Render for AcpThreadView {
4849 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4850 let has_messages = self.list_state.item_count() > 0;
4851 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
4852
4853 v_flex()
4854 .size_full()
4855 .key_context("AcpThread")
4856 .on_action(cx.listener(Self::open_agent_diff))
4857 .on_action(cx.listener(Self::toggle_burn_mode))
4858 .on_action(cx.listener(Self::keep_all))
4859 .on_action(cx.listener(Self::reject_all))
4860 .track_focus(&self.focus_handle)
4861 .bg(cx.theme().colors().panel_background)
4862 .child(match &self.thread_state {
4863 ThreadState::Unauthenticated {
4864 connection,
4865 description,
4866 configuration_view,
4867 pending_auth_method,
4868 ..
4869 } => self.render_auth_required_state(
4870 connection,
4871 description.as_ref(),
4872 configuration_view.as_ref(),
4873 pending_auth_method.as_ref(),
4874 window,
4875 cx,
4876 ),
4877 ThreadState::Loading { .. } => v_flex()
4878 .flex_1()
4879 .child(self.render_recent_history(window, cx)),
4880 ThreadState::LoadError(e) => v_flex()
4881 .flex_1()
4882 .size_full()
4883 .items_center()
4884 .justify_end()
4885 .child(self.render_load_error(e, cx)),
4886 ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
4887 if has_messages {
4888 this.child(
4889 list(
4890 self.list_state.clone(),
4891 cx.processor(|this, index: usize, window, cx| {
4892 let Some((entry, len)) = this.thread().and_then(|thread| {
4893 let entries = &thread.read(cx).entries();
4894 Some((entries.get(index)?, entries.len()))
4895 }) else {
4896 return Empty.into_any();
4897 };
4898 this.render_entry(index, len, entry, window, cx)
4899 }),
4900 )
4901 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
4902 .flex_grow()
4903 .into_any(),
4904 )
4905 .child(self.render_vertical_scrollbar(cx))
4906 } else {
4907 this.child(self.render_recent_history(window, cx))
4908 }
4909 }),
4910 })
4911 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
4912 // above so that the scrollbar doesn't render behind it. The current setup allows
4913 // the scrollbar to stop exactly at the activity bar start.
4914 .when(has_messages, |this| match &self.thread_state {
4915 ThreadState::Ready { thread, .. } => {
4916 this.children(self.render_activity_bar(thread, window, cx))
4917 }
4918 _ => this,
4919 })
4920 .children(self.render_thread_retry_status_callout(window, cx))
4921 .children(self.render_thread_error(window, cx))
4922 .children(
4923 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
4924 Some(usage_callout.into_any_element())
4925 } else {
4926 self.render_token_limit_callout(line_height, cx)
4927 .map(|token_limit_callout| token_limit_callout.into_any_element())
4928 },
4929 )
4930 .child(self.render_message_editor(window, cx))
4931 }
4932}
4933
4934fn default_markdown_style(
4935 buffer_font: bool,
4936 muted_text: bool,
4937 window: &Window,
4938 cx: &App,
4939) -> MarkdownStyle {
4940 let theme_settings = ThemeSettings::get_global(cx);
4941 let colors = cx.theme().colors();
4942
4943 let buffer_font_size = TextSize::Small.rems(cx);
4944
4945 let mut text_style = window.text_style();
4946 let line_height = buffer_font_size * 1.75;
4947
4948 let font_family = if buffer_font {
4949 theme_settings.buffer_font.family.clone()
4950 } else {
4951 theme_settings.ui_font.family.clone()
4952 };
4953
4954 let font_size = if buffer_font {
4955 TextSize::Small.rems(cx)
4956 } else {
4957 TextSize::Default.rems(cx)
4958 };
4959
4960 let text_color = if muted_text {
4961 colors.text_muted
4962 } else {
4963 colors.text
4964 };
4965
4966 text_style.refine(&TextStyleRefinement {
4967 font_family: Some(font_family),
4968 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
4969 font_features: Some(theme_settings.ui_font.features.clone()),
4970 font_size: Some(font_size.into()),
4971 line_height: Some(line_height.into()),
4972 color: Some(text_color),
4973 ..Default::default()
4974 });
4975
4976 MarkdownStyle {
4977 base_text_style: text_style.clone(),
4978 syntax: cx.theme().syntax().clone(),
4979 selection_background_color: colors.element_selection_background,
4980 code_block_overflow_x_scroll: true,
4981 table_overflow_x_scroll: true,
4982 heading_level_styles: Some(HeadingLevelStyles {
4983 h1: Some(TextStyleRefinement {
4984 font_size: Some(rems(1.15).into()),
4985 ..Default::default()
4986 }),
4987 h2: Some(TextStyleRefinement {
4988 font_size: Some(rems(1.1).into()),
4989 ..Default::default()
4990 }),
4991 h3: Some(TextStyleRefinement {
4992 font_size: Some(rems(1.05).into()),
4993 ..Default::default()
4994 }),
4995 h4: Some(TextStyleRefinement {
4996 font_size: Some(rems(1.).into()),
4997 ..Default::default()
4998 }),
4999 h5: Some(TextStyleRefinement {
5000 font_size: Some(rems(0.95).into()),
5001 ..Default::default()
5002 }),
5003 h6: Some(TextStyleRefinement {
5004 font_size: Some(rems(0.875).into()),
5005 ..Default::default()
5006 }),
5007 }),
5008 code_block: StyleRefinement {
5009 padding: EdgesRefinement {
5010 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5011 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5012 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5013 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5014 },
5015 margin: EdgesRefinement {
5016 top: Some(Length::Definite(Pixels(8.).into())),
5017 left: Some(Length::Definite(Pixels(0.).into())),
5018 right: Some(Length::Definite(Pixels(0.).into())),
5019 bottom: Some(Length::Definite(Pixels(12.).into())),
5020 },
5021 border_style: Some(BorderStyle::Solid),
5022 border_widths: EdgesRefinement {
5023 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
5024 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
5025 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
5026 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
5027 },
5028 border_color: Some(colors.border_variant),
5029 background: Some(colors.editor_background.into()),
5030 text: Some(TextStyleRefinement {
5031 font_family: Some(theme_settings.buffer_font.family.clone()),
5032 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
5033 font_features: Some(theme_settings.buffer_font.features.clone()),
5034 font_size: Some(buffer_font_size.into()),
5035 ..Default::default()
5036 }),
5037 ..Default::default()
5038 },
5039 inline_code: TextStyleRefinement {
5040 font_family: Some(theme_settings.buffer_font.family.clone()),
5041 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
5042 font_features: Some(theme_settings.buffer_font.features.clone()),
5043 font_size: Some(buffer_font_size.into()),
5044 background_color: Some(colors.editor_foreground.opacity(0.08)),
5045 ..Default::default()
5046 },
5047 link: TextStyleRefinement {
5048 background_color: Some(colors.editor_foreground.opacity(0.025)),
5049 underline: Some(UnderlineStyle {
5050 color: Some(colors.text_accent.opacity(0.5)),
5051 thickness: px(1.),
5052 ..Default::default()
5053 }),
5054 ..Default::default()
5055 },
5056 ..Default::default()
5057 }
5058}
5059
5060fn plan_label_markdown_style(
5061 status: &acp::PlanEntryStatus,
5062 window: &Window,
5063 cx: &App,
5064) -> MarkdownStyle {
5065 let default_md_style = default_markdown_style(false, false, window, cx);
5066
5067 MarkdownStyle {
5068 base_text_style: TextStyle {
5069 color: cx.theme().colors().text_muted,
5070 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
5071 Some(gpui::StrikethroughStyle {
5072 thickness: px(1.),
5073 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
5074 })
5075 } else {
5076 None
5077 },
5078 ..default_md_style.base_text_style
5079 },
5080 ..default_md_style
5081 }
5082}
5083
5084fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
5085 let default_md_style = default_markdown_style(true, false, window, cx);
5086
5087 MarkdownStyle {
5088 base_text_style: TextStyle {
5089 ..default_md_style.base_text_style
5090 },
5091 selection_background_color: cx.theme().colors().element_selection_background,
5092 ..Default::default()
5093 }
5094}
5095
5096#[cfg(test)]
5097pub(crate) mod tests {
5098 use acp_thread::StubAgentConnection;
5099 use agent_client_protocol::SessionId;
5100 use assistant_context::ContextStore;
5101 use editor::EditorSettings;
5102 use fs::FakeFs;
5103 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
5104 use project::Project;
5105 use serde_json::json;
5106 use settings::SettingsStore;
5107 use std::any::Any;
5108 use std::path::Path;
5109 use workspace::Item;
5110
5111 use super::*;
5112
5113 #[gpui::test]
5114 async fn test_drop(cx: &mut TestAppContext) {
5115 init_test(cx);
5116
5117 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
5118 let weak_view = thread_view.downgrade();
5119 drop(thread_view);
5120 assert!(!weak_view.is_upgradable());
5121 }
5122
5123 #[gpui::test]
5124 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
5125 init_test(cx);
5126
5127 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
5128
5129 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5130 message_editor.update_in(cx, |editor, window, cx| {
5131 editor.set_text("Hello", window, cx);
5132 });
5133
5134 cx.deactivate_window();
5135
5136 thread_view.update_in(cx, |thread_view, window, cx| {
5137 thread_view.send(window, cx);
5138 });
5139
5140 cx.run_until_parked();
5141
5142 assert!(
5143 cx.windows()
5144 .iter()
5145 .any(|window| window.downcast::<AgentNotification>().is_some())
5146 );
5147 }
5148
5149 #[gpui::test]
5150 async fn test_notification_for_error(cx: &mut TestAppContext) {
5151 init_test(cx);
5152
5153 let (thread_view, cx) =
5154 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
5155
5156 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5157 message_editor.update_in(cx, |editor, window, cx| {
5158 editor.set_text("Hello", window, cx);
5159 });
5160
5161 cx.deactivate_window();
5162
5163 thread_view.update_in(cx, |thread_view, window, cx| {
5164 thread_view.send(window, cx);
5165 });
5166
5167 cx.run_until_parked();
5168
5169 assert!(
5170 cx.windows()
5171 .iter()
5172 .any(|window| window.downcast::<AgentNotification>().is_some())
5173 );
5174 }
5175
5176 #[gpui::test]
5177 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
5178 init_test(cx);
5179
5180 let tool_call_id = acp::ToolCallId("1".into());
5181 let tool_call = acp::ToolCall {
5182 id: tool_call_id.clone(),
5183 title: "Label".into(),
5184 kind: acp::ToolKind::Edit,
5185 status: acp::ToolCallStatus::Pending,
5186 content: vec!["hi".into()],
5187 locations: vec![],
5188 raw_input: None,
5189 raw_output: None,
5190 };
5191 let connection =
5192 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5193 tool_call_id,
5194 vec![acp::PermissionOption {
5195 id: acp::PermissionOptionId("1".into()),
5196 name: "Allow".into(),
5197 kind: acp::PermissionOptionKind::AllowOnce,
5198 }],
5199 )]));
5200
5201 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5202
5203 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5204
5205 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5206 message_editor.update_in(cx, |editor, window, cx| {
5207 editor.set_text("Hello", window, cx);
5208 });
5209
5210 cx.deactivate_window();
5211
5212 thread_view.update_in(cx, |thread_view, window, cx| {
5213 thread_view.send(window, cx);
5214 });
5215
5216 cx.run_until_parked();
5217
5218 assert!(
5219 cx.windows()
5220 .iter()
5221 .any(|window| window.downcast::<AgentNotification>().is_some())
5222 );
5223 }
5224
5225 async fn setup_thread_view(
5226 agent: impl AgentServer + 'static,
5227 cx: &mut TestAppContext,
5228 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
5229 let fs = FakeFs::new(cx.executor());
5230 let project = Project::test(fs, [], cx).await;
5231 let (workspace, cx) =
5232 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5233
5234 let context_store =
5235 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5236 let history_store =
5237 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5238
5239 let thread_view = cx.update(|window, cx| {
5240 cx.new(|cx| {
5241 AcpThreadView::new(
5242 Rc::new(agent),
5243 None,
5244 None,
5245 workspace.downgrade(),
5246 project,
5247 history_store,
5248 None,
5249 window,
5250 cx,
5251 )
5252 })
5253 });
5254 cx.run_until_parked();
5255 (thread_view, cx)
5256 }
5257
5258 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
5259 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
5260
5261 workspace
5262 .update_in(cx, |workspace, window, cx| {
5263 workspace.add_item_to_active_pane(
5264 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
5265 None,
5266 true,
5267 window,
5268 cx,
5269 );
5270 })
5271 .unwrap();
5272 }
5273
5274 struct ThreadViewItem(Entity<AcpThreadView>);
5275
5276 impl Item for ThreadViewItem {
5277 type Event = ();
5278
5279 fn include_in_nav_history() -> bool {
5280 false
5281 }
5282
5283 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
5284 "Test".into()
5285 }
5286 }
5287
5288 impl EventEmitter<()> for ThreadViewItem {}
5289
5290 impl Focusable for ThreadViewItem {
5291 fn focus_handle(&self, cx: &App) -> FocusHandle {
5292 self.0.read(cx).focus_handle(cx)
5293 }
5294 }
5295
5296 impl Render for ThreadViewItem {
5297 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5298 self.0.clone().into_any_element()
5299 }
5300 }
5301
5302 struct StubAgentServer<C> {
5303 connection: C,
5304 }
5305
5306 impl<C> StubAgentServer<C> {
5307 fn new(connection: C) -> Self {
5308 Self { connection }
5309 }
5310 }
5311
5312 impl StubAgentServer<StubAgentConnection> {
5313 fn default_response() -> Self {
5314 let conn = StubAgentConnection::new();
5315 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5316 content: "Default response".into(),
5317 }]);
5318 Self::new(conn)
5319 }
5320 }
5321
5322 impl<C> AgentServer for StubAgentServer<C>
5323 where
5324 C: 'static + AgentConnection + Send + Clone,
5325 {
5326 fn telemetry_id(&self) -> &'static str {
5327 "test"
5328 }
5329
5330 fn logo(&self) -> ui::IconName {
5331 ui::IconName::Ai
5332 }
5333
5334 fn name(&self) -> SharedString {
5335 "Test".into()
5336 }
5337
5338 fn empty_state_headline(&self) -> SharedString {
5339 "Test".into()
5340 }
5341
5342 fn empty_state_message(&self) -> SharedString {
5343 "Test".into()
5344 }
5345
5346 fn connect(
5347 &self,
5348 _root_dir: &Path,
5349 _project: &Entity<Project>,
5350 _cx: &mut App,
5351 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5352 Task::ready(Ok(Rc::new(self.connection.clone())))
5353 }
5354
5355 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5356 self
5357 }
5358 }
5359
5360 #[derive(Clone)]
5361 struct SaboteurAgentConnection;
5362
5363 impl AgentConnection for SaboteurAgentConnection {
5364 fn new_thread(
5365 self: Rc<Self>,
5366 project: Entity<Project>,
5367 _cwd: &Path,
5368 cx: &mut gpui::App,
5369 ) -> Task<gpui::Result<Entity<AcpThread>>> {
5370 Task::ready(Ok(cx.new(|cx| {
5371 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5372 AcpThread::new(
5373 "SaboteurAgentConnection",
5374 self,
5375 project,
5376 action_log,
5377 SessionId("test".into()),
5378 watch::Receiver::constant(acp::PromptCapabilities {
5379 image: true,
5380 audio: true,
5381 embedded_context: true,
5382 }),
5383 cx,
5384 )
5385 })))
5386 }
5387
5388 fn auth_methods(&self) -> &[acp::AuthMethod] {
5389 &[]
5390 }
5391
5392 fn authenticate(
5393 &self,
5394 _method_id: acp::AuthMethodId,
5395 _cx: &mut App,
5396 ) -> Task<gpui::Result<()>> {
5397 unimplemented!()
5398 }
5399
5400 fn prompt(
5401 &self,
5402 _id: Option<acp_thread::UserMessageId>,
5403 _params: acp::PromptRequest,
5404 _cx: &mut App,
5405 ) -> Task<gpui::Result<acp::PromptResponse>> {
5406 Task::ready(Err(anyhow::anyhow!("Error prompting")))
5407 }
5408
5409 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
5410 unimplemented!()
5411 }
5412
5413 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5414 self
5415 }
5416 }
5417
5418 pub(crate) fn init_test(cx: &mut TestAppContext) {
5419 cx.update(|cx| {
5420 let settings_store = SettingsStore::test(cx);
5421 cx.set_global(settings_store);
5422 language::init(cx);
5423 Project::init_settings(cx);
5424 AgentSettings::register(cx);
5425 workspace::init_settings(cx);
5426 ThemeSettings::register(cx);
5427 release_channel::init(SemanticVersion::default(), cx);
5428 EditorSettings::register(cx);
5429 prompt_store::init(cx)
5430 });
5431 }
5432
5433 #[gpui::test]
5434 async fn test_rewind_views(cx: &mut TestAppContext) {
5435 init_test(cx);
5436
5437 let fs = FakeFs::new(cx.executor());
5438 fs.insert_tree(
5439 "/project",
5440 json!({
5441 "test1.txt": "old content 1",
5442 "test2.txt": "old content 2"
5443 }),
5444 )
5445 .await;
5446 let project = Project::test(fs, [Path::new("/project")], cx).await;
5447 let (workspace, cx) =
5448 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5449
5450 let context_store =
5451 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5452 let history_store =
5453 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5454
5455 let connection = Rc::new(StubAgentConnection::new());
5456 let thread_view = cx.update(|window, cx| {
5457 cx.new(|cx| {
5458 AcpThreadView::new(
5459 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
5460 None,
5461 None,
5462 workspace.downgrade(),
5463 project.clone(),
5464 history_store.clone(),
5465 None,
5466 window,
5467 cx,
5468 )
5469 })
5470 });
5471
5472 cx.run_until_parked();
5473
5474 let thread = thread_view
5475 .read_with(cx, |view, _| view.thread().cloned())
5476 .unwrap();
5477
5478 // First user message
5479 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5480 id: acp::ToolCallId("tool1".into()),
5481 title: "Edit file 1".into(),
5482 kind: acp::ToolKind::Edit,
5483 status: acp::ToolCallStatus::Completed,
5484 content: vec![acp::ToolCallContent::Diff {
5485 diff: acp::Diff {
5486 path: "/project/test1.txt".into(),
5487 old_text: Some("old content 1".into()),
5488 new_text: "new content 1".into(),
5489 },
5490 }],
5491 locations: vec![],
5492 raw_input: None,
5493 raw_output: None,
5494 })]);
5495
5496 thread
5497 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
5498 .await
5499 .unwrap();
5500 cx.run_until_parked();
5501
5502 thread.read_with(cx, |thread, _| {
5503 assert_eq!(thread.entries().len(), 2);
5504 });
5505
5506 thread_view.read_with(cx, |view, cx| {
5507 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5508 assert!(
5509 entry_view_state
5510 .entry(0)
5511 .unwrap()
5512 .message_editor()
5513 .is_some()
5514 );
5515 assert!(entry_view_state.entry(1).unwrap().has_content());
5516 });
5517 });
5518
5519 // Second user message
5520 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5521 id: acp::ToolCallId("tool2".into()),
5522 title: "Edit file 2".into(),
5523 kind: acp::ToolKind::Edit,
5524 status: acp::ToolCallStatus::Completed,
5525 content: vec![acp::ToolCallContent::Diff {
5526 diff: acp::Diff {
5527 path: "/project/test2.txt".into(),
5528 old_text: Some("old content 2".into()),
5529 new_text: "new content 2".into(),
5530 },
5531 }],
5532 locations: vec![],
5533 raw_input: None,
5534 raw_output: None,
5535 })]);
5536
5537 thread
5538 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
5539 .await
5540 .unwrap();
5541 cx.run_until_parked();
5542
5543 let second_user_message_id = thread.read_with(cx, |thread, _| {
5544 assert_eq!(thread.entries().len(), 4);
5545 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
5546 panic!();
5547 };
5548 user_message.id.clone().unwrap()
5549 });
5550
5551 thread_view.read_with(cx, |view, cx| {
5552 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5553 assert!(
5554 entry_view_state
5555 .entry(0)
5556 .unwrap()
5557 .message_editor()
5558 .is_some()
5559 );
5560 assert!(entry_view_state.entry(1).unwrap().has_content());
5561 assert!(
5562 entry_view_state
5563 .entry(2)
5564 .unwrap()
5565 .message_editor()
5566 .is_some()
5567 );
5568 assert!(entry_view_state.entry(3).unwrap().has_content());
5569 });
5570 });
5571
5572 // Rewind to first message
5573 thread
5574 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
5575 .await
5576 .unwrap();
5577
5578 cx.run_until_parked();
5579
5580 thread.read_with(cx, |thread, _| {
5581 assert_eq!(thread.entries().len(), 2);
5582 });
5583
5584 thread_view.read_with(cx, |view, cx| {
5585 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5586 assert!(
5587 entry_view_state
5588 .entry(0)
5589 .unwrap()
5590 .message_editor()
5591 .is_some()
5592 );
5593 assert!(entry_view_state.entry(1).unwrap().has_content());
5594
5595 // Old views should be dropped
5596 assert!(entry_view_state.entry(2).is_none());
5597 assert!(entry_view_state.entry(3).is_none());
5598 });
5599 });
5600 }
5601
5602 #[gpui::test]
5603 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5604 init_test(cx);
5605
5606 let connection = StubAgentConnection::new();
5607
5608 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5609 content: acp::ContentBlock::Text(acp::TextContent {
5610 text: "Response".into(),
5611 annotations: None,
5612 }),
5613 }]);
5614
5615 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5616 add_to_workspace(thread_view.clone(), cx);
5617
5618 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5619 message_editor.update_in(cx, |editor, window, cx| {
5620 editor.set_text("Original message to edit", window, cx);
5621 });
5622 thread_view.update_in(cx, |thread_view, window, cx| {
5623 thread_view.send(window, cx);
5624 });
5625
5626 cx.run_until_parked();
5627
5628 let user_message_editor = thread_view.read_with(cx, |view, cx| {
5629 assert_eq!(view.editing_message, None);
5630
5631 view.entry_view_state
5632 .read(cx)
5633 .entry(0)
5634 .unwrap()
5635 .message_editor()
5636 .unwrap()
5637 .clone()
5638 });
5639
5640 // Focus
5641 cx.focus(&user_message_editor);
5642 thread_view.read_with(cx, |view, _cx| {
5643 assert_eq!(view.editing_message, Some(0));
5644 });
5645
5646 // Edit
5647 user_message_editor.update_in(cx, |editor, window, cx| {
5648 editor.set_text("Edited message content", window, cx);
5649 });
5650
5651 // Cancel
5652 user_message_editor.update_in(cx, |_editor, window, cx| {
5653 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
5654 });
5655
5656 thread_view.read_with(cx, |view, _cx| {
5657 assert_eq!(view.editing_message, None);
5658 });
5659
5660 user_message_editor.read_with(cx, |editor, cx| {
5661 assert_eq!(editor.text(cx), "Original message to edit");
5662 });
5663 }
5664
5665 #[gpui::test]
5666 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
5667 init_test(cx);
5668
5669 let connection = StubAgentConnection::new();
5670
5671 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5672 add_to_workspace(thread_view.clone(), cx);
5673
5674 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5675 let mut events = cx.events(&message_editor);
5676 message_editor.update_in(cx, |editor, window, cx| {
5677 editor.set_text("", window, cx);
5678 });
5679
5680 message_editor.update_in(cx, |_editor, window, cx| {
5681 window.dispatch_action(Box::new(Chat), cx);
5682 });
5683 cx.run_until_parked();
5684 // We shouldn't have received any messages
5685 assert!(matches!(
5686 events.try_next(),
5687 Err(futures::channel::mpsc::TryRecvError { .. })
5688 ));
5689 }
5690
5691 #[gpui::test]
5692 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
5693 init_test(cx);
5694
5695 let connection = StubAgentConnection::new();
5696
5697 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5698 content: acp::ContentBlock::Text(acp::TextContent {
5699 text: "Response".into(),
5700 annotations: None,
5701 }),
5702 }]);
5703
5704 let (thread_view, cx) =
5705 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5706 add_to_workspace(thread_view.clone(), cx);
5707
5708 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5709 message_editor.update_in(cx, |editor, window, cx| {
5710 editor.set_text("Original message to edit", window, cx);
5711 });
5712 thread_view.update_in(cx, |thread_view, window, cx| {
5713 thread_view.send(window, cx);
5714 });
5715
5716 cx.run_until_parked();
5717
5718 let user_message_editor = thread_view.read_with(cx, |view, cx| {
5719 assert_eq!(view.editing_message, None);
5720 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
5721
5722 view.entry_view_state
5723 .read(cx)
5724 .entry(0)
5725 .unwrap()
5726 .message_editor()
5727 .unwrap()
5728 .clone()
5729 });
5730
5731 // Focus
5732 cx.focus(&user_message_editor);
5733
5734 // Edit
5735 user_message_editor.update_in(cx, |editor, window, cx| {
5736 editor.set_text("Edited message content", window, cx);
5737 });
5738
5739 // Send
5740 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5741 content: acp::ContentBlock::Text(acp::TextContent {
5742 text: "New Response".into(),
5743 annotations: None,
5744 }),
5745 }]);
5746
5747 user_message_editor.update_in(cx, |_editor, window, cx| {
5748 window.dispatch_action(Box::new(Chat), cx);
5749 });
5750
5751 cx.run_until_parked();
5752
5753 thread_view.read_with(cx, |view, cx| {
5754 assert_eq!(view.editing_message, None);
5755
5756 let entries = view.thread().unwrap().read(cx).entries();
5757 assert_eq!(entries.len(), 2);
5758 assert_eq!(
5759 entries[0].to_markdown(cx),
5760 "## User\n\nEdited message content\n\n"
5761 );
5762 assert_eq!(
5763 entries[1].to_markdown(cx),
5764 "## Assistant\n\nNew Response\n\n"
5765 );
5766
5767 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
5768 assert!(!state.entry(1).unwrap().has_content());
5769 state.entry(0).unwrap().message_editor().unwrap().clone()
5770 });
5771
5772 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
5773 })
5774 }
5775
5776 #[gpui::test]
5777 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
5778 init_test(cx);
5779
5780 let connection = StubAgentConnection::new();
5781
5782 let (thread_view, cx) =
5783 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5784 add_to_workspace(thread_view.clone(), cx);
5785
5786 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5787 message_editor.update_in(cx, |editor, window, cx| {
5788 editor.set_text("Original message to edit", window, cx);
5789 });
5790 thread_view.update_in(cx, |thread_view, window, cx| {
5791 thread_view.send(window, cx);
5792 });
5793
5794 cx.run_until_parked();
5795
5796 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
5797 let thread = view.thread().unwrap().read(cx);
5798 assert_eq!(thread.entries().len(), 1);
5799
5800 let editor = view
5801 .entry_view_state
5802 .read(cx)
5803 .entry(0)
5804 .unwrap()
5805 .message_editor()
5806 .unwrap()
5807 .clone();
5808
5809 (editor, thread.session_id().clone())
5810 });
5811
5812 // Focus
5813 cx.focus(&user_message_editor);
5814
5815 thread_view.read_with(cx, |view, _cx| {
5816 assert_eq!(view.editing_message, Some(0));
5817 });
5818
5819 // Edit
5820 user_message_editor.update_in(cx, |editor, window, cx| {
5821 editor.set_text("Edited message content", window, cx);
5822 });
5823
5824 thread_view.read_with(cx, |view, _cx| {
5825 assert_eq!(view.editing_message, Some(0));
5826 });
5827
5828 // Finish streaming response
5829 cx.update(|_, cx| {
5830 connection.send_update(
5831 session_id.clone(),
5832 acp::SessionUpdate::AgentMessageChunk {
5833 content: acp::ContentBlock::Text(acp::TextContent {
5834 text: "Response".into(),
5835 annotations: None,
5836 }),
5837 },
5838 cx,
5839 );
5840 connection.end_turn(session_id, acp::StopReason::EndTurn);
5841 });
5842
5843 thread_view.read_with(cx, |view, _cx| {
5844 assert_eq!(view.editing_message, Some(0));
5845 });
5846
5847 cx.run_until_parked();
5848
5849 // Should still be editing
5850 cx.update(|window, cx| {
5851 assert!(user_message_editor.focus_handle(cx).is_focused(window));
5852 assert_eq!(thread_view.read(cx).editing_message, Some(0));
5853 assert_eq!(
5854 user_message_editor.read(cx).text(cx),
5855 "Edited message content"
5856 );
5857 });
5858 }
5859
5860 #[gpui::test]
5861 async fn test_interrupt(cx: &mut TestAppContext) {
5862 init_test(cx);
5863
5864 let connection = StubAgentConnection::new();
5865
5866 let (thread_view, cx) =
5867 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5868 add_to_workspace(thread_view.clone(), cx);
5869
5870 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5871 message_editor.update_in(cx, |editor, window, cx| {
5872 editor.set_text("Message 1", window, cx);
5873 });
5874 thread_view.update_in(cx, |thread_view, window, cx| {
5875 thread_view.send(window, cx);
5876 });
5877
5878 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
5879 let thread = view.thread().unwrap();
5880
5881 (thread.clone(), thread.read(cx).session_id().clone())
5882 });
5883
5884 cx.run_until_parked();
5885
5886 cx.update(|_, cx| {
5887 connection.send_update(
5888 session_id.clone(),
5889 acp::SessionUpdate::AgentMessageChunk {
5890 content: "Message 1 resp".into(),
5891 },
5892 cx,
5893 );
5894 });
5895
5896 cx.run_until_parked();
5897
5898 thread.read_with(cx, |thread, cx| {
5899 assert_eq!(
5900 thread.to_markdown(cx),
5901 indoc::indoc! {"
5902 ## User
5903
5904 Message 1
5905
5906 ## Assistant
5907
5908 Message 1 resp
5909
5910 "}
5911 )
5912 });
5913
5914 message_editor.update_in(cx, |editor, window, cx| {
5915 editor.set_text("Message 2", window, cx);
5916 });
5917 thread_view.update_in(cx, |thread_view, window, cx| {
5918 thread_view.send(window, cx);
5919 });
5920
5921 cx.update(|_, cx| {
5922 // Simulate a response sent after beginning to cancel
5923 connection.send_update(
5924 session_id.clone(),
5925 acp::SessionUpdate::AgentMessageChunk {
5926 content: "onse".into(),
5927 },
5928 cx,
5929 );
5930 });
5931
5932 cx.run_until_parked();
5933
5934 // Last Message 1 response should appear before Message 2
5935 thread.read_with(cx, |thread, cx| {
5936 assert_eq!(
5937 thread.to_markdown(cx),
5938 indoc::indoc! {"
5939 ## User
5940
5941 Message 1
5942
5943 ## Assistant
5944
5945 Message 1 response
5946
5947 ## User
5948
5949 Message 2
5950
5951 "}
5952 )
5953 });
5954
5955 cx.update(|_, cx| {
5956 connection.send_update(
5957 session_id.clone(),
5958 acp::SessionUpdate::AgentMessageChunk {
5959 content: "Message 2 response".into(),
5960 },
5961 cx,
5962 );
5963 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5964 });
5965
5966 cx.run_until_parked();
5967
5968 thread.read_with(cx, |thread, cx| {
5969 assert_eq!(
5970 thread.to_markdown(cx),
5971 indoc::indoc! {"
5972 ## User
5973
5974 Message 1
5975
5976 ## Assistant
5977
5978 Message 1 response
5979
5980 ## User
5981
5982 Message 2
5983
5984 ## Assistant
5985
5986 Message 2 response
5987
5988 "}
5989 )
5990 });
5991 }
5992}