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