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