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