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