1use acp_thread::{
2 AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
3 AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice,
4 PermissionOptions, RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
5 UserMessageId,
6};
7use acp_thread::{AgentConnection, Plan};
8use action_log::{ActionLog, ActionLogTelemetry};
9use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
10use agent_client_protocol::{self as acp, PromptCapabilities};
11use agent_servers::{AgentServer, AgentServerDelegate};
12use agent_settings::{AgentProfileId, AgentSettings};
13use anyhow::{Result, anyhow};
14use arrayvec::ArrayVec;
15use audio::{Audio, Sound};
16use buffer_diff::BufferDiff;
17use client::zed_urls;
18use collections::{HashMap, HashSet};
19use editor::scroll::Autoscroll;
20use editor::{
21 Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
22};
23use feature_flags::{
24 AgentSharingFeatureFlag, AgentV2FeatureFlag, CloudThinkingEffortFeatureFlag,
25 FeatureFlagAppExt as _,
26};
27use file_icons::FileIcons;
28use fs::Fs;
29use futures::FutureExt as _;
30use gpui::{
31 Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
32 ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit,
33 PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, WeakEntity, Window,
34 WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point,
35 pulsating_between,
36};
37use language::Buffer;
38use language_model::LanguageModelRegistry;
39use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
40use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
41use prompt_store::{PromptId, PromptStore};
42use rope::Point;
43use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
44use std::cell::RefCell;
45use std::path::Path;
46use std::sync::Arc;
47use std::time::Instant;
48use std::{collections::BTreeMap, rc::Rc, time::Duration};
49use terminal_view::terminal_panel::TerminalPanel;
50use text::{Anchor, ToPoint as _};
51use theme::AgentFontSize;
52use ui::{
53 Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, DecoratedIcon,
54 DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, KeyBinding,
55 PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
56 right_click_menu,
57};
58use util::defer;
59use util::{ResultExt, size::format_file_size, time::duration_alt_display};
60use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
61use zed_actions::agent::{Chat, ToggleModelSelector};
62use zed_actions::assistant::OpenRulesLibrary;
63
64use super::config_options::ConfigOptionsView;
65use super::entry_view_state::EntryViewState;
66use super::thread_history::AcpThreadHistory;
67use crate::acp::AcpModelSelectorPopover;
68use crate::acp::ModeSelector;
69use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
70use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
71use crate::agent_diff::AgentDiff;
72use crate::profile_selector::{ProfileProvider, ProfileSelector};
73use crate::ui::{AgentNotification, AgentNotificationEvent};
74use crate::{
75 AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
76 CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, EditFirstQueuedMessage,
77 ExpandMessageEditor, ExternalAgentInitialContent, Follow, KeepAll, NewThread,
78 OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
79 RemoveFirstQueuedMessage, SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage,
80 ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode,
81};
82
83const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
84const TOKEN_THRESHOLD: u64 = 250;
85
86mod active_thread;
87pub use active_thread::*;
88
89pub struct QueuedMessage {
90 pub content: Vec<acp::ContentBlock>,
91 pub tracked_buffers: Vec<Entity<Buffer>>,
92}
93
94#[derive(Copy, Clone, Debug, PartialEq, Eq)]
95enum ThreadFeedback {
96 Positive,
97 Negative,
98}
99
100#[derive(Debug)]
101pub(crate) enum ThreadError {
102 PaymentRequired,
103 Refusal,
104 AuthenticationRequired(SharedString),
105 Other {
106 message: SharedString,
107 acp_error_code: Option<SharedString>,
108 },
109}
110
111impl ThreadError {
112 fn from_err(error: anyhow::Error, agent_name: &str) -> Self {
113 if error.is::<language_model::PaymentRequiredError>() {
114 Self::PaymentRequired
115 } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
116 && acp_error.code == acp::ErrorCode::AuthRequired
117 {
118 Self::AuthenticationRequired(acp_error.message.clone().into())
119 } else {
120 let message: SharedString = format!("{:#}", error).into();
121
122 // Extract ACP error code if available
123 let acp_error_code = error
124 .downcast_ref::<acp::Error>()
125 .map(|acp_error| SharedString::from(acp_error.code.to_string()));
126
127 // TODO: we should have Gemini return better errors here.
128 if agent_name == "Gemini CLI"
129 && message.contains("Could not load the default credentials")
130 || message.contains("API key not valid")
131 || message.contains("Request had invalid authentication credentials")
132 {
133 Self::AuthenticationRequired(message)
134 } else {
135 Self::Other {
136 message,
137 acp_error_code,
138 }
139 }
140 }
141 }
142}
143
144impl ProfileProvider for Entity<agent::Thread> {
145 fn profile_id(&self, cx: &App) -> AgentProfileId {
146 self.read(cx).profile().clone()
147 }
148
149 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
150 self.update(cx, |thread, cx| {
151 // Apply the profile and let the thread swap to its default model.
152 thread.set_profile(profile_id, cx);
153 });
154 }
155
156 fn profiles_supported(&self, cx: &App) -> bool {
157 self.read(cx)
158 .model()
159 .is_some_and(|model| model.supports_tools())
160 }
161}
162
163pub struct AcpServerView {
164 agent: Rc<dyn AgentServer>,
165 agent_server_store: Entity<AgentServerStore>,
166 workspace: WeakEntity<Workspace>,
167 project: Entity<Project>,
168 thread_store: Option<Entity<ThreadStore>>,
169 prompt_store: Option<Entity<PromptStore>>,
170 server_state: ServerState,
171 login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
172 history: Entity<AcpThreadHistory>,
173 focus_handle: FocusHandle,
174 notifications: Vec<WindowHandle<AgentNotification>>,
175 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
176 auth_task: Option<Task<()>>,
177 _subscriptions: Vec<Subscription>,
178}
179
180impl AcpServerView {
181 pub fn active_thread(&self) -> Option<Entity<AcpThreadView>> {
182 match &self.server_state {
183 ServerState::Connected(connected) => Some(connected.current.clone()),
184 _ => None,
185 }
186 }
187
188 pub fn parent_thread(&self, cx: &App) -> Option<Entity<AcpThreadView>> {
189 match &self.server_state {
190 ServerState::Connected(connected) => {
191 let mut current = connected.current.clone();
192 while let Some(parent_id) = current.read(cx).parent_id.clone() {
193 if let Some(parent) = connected.threads.get(&parent_id) {
194 current = parent.clone();
195 } else {
196 break;
197 }
198 }
199 Some(current)
200 }
201 _ => None,
202 }
203 }
204
205 pub fn thread_view(&self, session_id: &acp::SessionId) -> Option<Entity<AcpThreadView>> {
206 let connected = self.as_connected()?;
207 connected.threads.get(session_id).cloned()
208 }
209
210 pub fn as_connected(&self) -> Option<&ConnectedServerState> {
211 match &self.server_state {
212 ServerState::Connected(connected) => Some(connected),
213 _ => None,
214 }
215 }
216
217 pub fn as_connected_mut(&mut self) -> Option<&mut ConnectedServerState> {
218 match &mut self.server_state {
219 ServerState::Connected(connected) => Some(connected),
220 _ => None,
221 }
222 }
223
224 pub fn navigate_to_session(
225 &mut self,
226 session_id: acp::SessionId,
227 window: &mut Window,
228 cx: &mut Context<Self>,
229 ) {
230 let Some(connected) = self.as_connected_mut() else {
231 return;
232 };
233
234 connected.navigate_to_session(session_id);
235 if let Some(view) = self.active_thread() {
236 view.focus_handle(cx).focus(window, cx);
237 }
238 cx.notify();
239 }
240}
241
242enum ServerState {
243 Loading(Entity<LoadingView>),
244 LoadError(LoadError),
245 Connected(ConnectedServerState),
246}
247
248// current -> Entity
249// hashmap of threads, current becomes session_id
250pub struct ConnectedServerState {
251 auth_state: AuthState,
252 current: Entity<AcpThreadView>,
253 threads: HashMap<acp::SessionId, Entity<AcpThreadView>>,
254 connection: Rc<dyn AgentConnection>,
255}
256
257enum AuthState {
258 Ok,
259 Unauthenticated {
260 description: Option<Entity<Markdown>>,
261 configuration_view: Option<AnyView>,
262 pending_auth_method: Option<acp::AuthMethodId>,
263 _subscription: Option<Subscription>,
264 },
265}
266
267impl AuthState {
268 pub fn is_ok(&self) -> bool {
269 matches!(self, Self::Ok)
270 }
271}
272
273struct LoadingView {
274 title: SharedString,
275 _load_task: Task<()>,
276 _update_title_task: Task<anyhow::Result<()>>,
277}
278
279impl ConnectedServerState {
280 pub fn has_thread_error(&self, cx: &App) -> bool {
281 self.current.read(cx).thread_error.is_some()
282 }
283
284 pub fn navigate_to_session(&mut self, session_id: acp::SessionId) {
285 if let Some(session) = self.threads.get(&session_id) {
286 self.current = session.clone();
287 }
288 }
289
290 pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
291 let tasks = self
292 .threads
293 .keys()
294 .map(|id| self.connection.close_session(id, cx));
295 let task = futures::future::join_all(tasks);
296 cx.background_spawn(async move {
297 task.await;
298 })
299 }
300}
301
302impl AcpServerView {
303 pub fn new(
304 agent: Rc<dyn AgentServer>,
305 resume_thread: Option<AgentSessionInfo>,
306 initial_content: Option<ExternalAgentInitialContent>,
307 workspace: WeakEntity<Workspace>,
308 project: Entity<Project>,
309 thread_store: Option<Entity<ThreadStore>>,
310 prompt_store: Option<Entity<PromptStore>>,
311 history: Entity<AcpThreadHistory>,
312 window: &mut Window,
313 cx: &mut Context<Self>,
314 ) -> Self {
315 let agent_server_store = project.read(cx).agent_server_store().clone();
316 let subscriptions = vec![
317 cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
318 cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
319 cx.subscribe_in(
320 &agent_server_store,
321 window,
322 Self::handle_agent_servers_updated,
323 ),
324 ];
325
326 cx.on_release(|this, cx| {
327 if let Some(connected) = this.as_connected() {
328 connected.close_all_sessions(cx).detach();
329 }
330 for window in this.notifications.drain(..) {
331 window
332 .update(cx, |_, window, _| {
333 window.remove_window();
334 })
335 .ok();
336 }
337 })
338 .detach();
339
340 Self {
341 agent: agent.clone(),
342 agent_server_store,
343 workspace,
344 project: project.clone(),
345 thread_store,
346 prompt_store,
347 server_state: Self::initial_state(
348 agent.clone(),
349 resume_thread,
350 project,
351 initial_content,
352 window,
353 cx,
354 ),
355 login: None,
356 notifications: Vec::new(),
357 notification_subscriptions: HashMap::default(),
358 auth_task: None,
359 history,
360 _subscriptions: subscriptions,
361 focus_handle: cx.focus_handle(),
362 }
363 }
364
365 fn set_server_state(&mut self, state: ServerState, cx: &mut Context<Self>) {
366 if let Some(connected) = self.as_connected() {
367 connected.close_all_sessions(cx).detach();
368 }
369
370 self.server_state = state;
371 cx.notify();
372 }
373
374 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
375 let resume_thread_metadata = self
376 .active_thread()
377 .and_then(|thread| thread.read(cx).resume_thread_metadata.clone());
378
379 let state = Self::initial_state(
380 self.agent.clone(),
381 resume_thread_metadata,
382 self.project.clone(),
383 None,
384 window,
385 cx,
386 );
387 self.set_server_state(state, cx);
388
389 if let Some(connected) = self.as_connected() {
390 connected.current.update(cx, |this, cx| {
391 this.message_editor.update(cx, |editor, cx| {
392 editor.set_command_state(
393 this.prompt_capabilities.clone(),
394 this.available_commands.clone(),
395 cx,
396 );
397 });
398 });
399 }
400 cx.notify();
401 }
402
403 fn initial_state(
404 agent: Rc<dyn AgentServer>,
405 resume_thread: Option<AgentSessionInfo>,
406 project: Entity<Project>,
407 initial_content: Option<ExternalAgentInitialContent>,
408 window: &mut Window,
409 cx: &mut Context<Self>,
410 ) -> ServerState {
411 if project.read(cx).is_via_collab()
412 && agent.clone().downcast::<NativeAgentServer>().is_none()
413 {
414 return ServerState::LoadError(LoadError::Other(
415 "External agents are not yet supported in shared projects.".into(),
416 ));
417 }
418 let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
419 // Pick the first non-single-file worktree for the root directory if there are any,
420 // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
421 worktrees.sort_by(|l, r| {
422 l.read(cx)
423 .is_single_file()
424 .cmp(&r.read(cx).is_single_file())
425 });
426 let worktree_roots: Vec<Arc<Path>> = worktrees
427 .iter()
428 .filter_map(|worktree| {
429 let worktree = worktree.read(cx);
430 if worktree.is_single_file() {
431 Some(worktree.abs_path().parent()?.into())
432 } else {
433 Some(worktree.abs_path())
434 }
435 })
436 .collect();
437 let root_dir = worktree_roots.first().cloned();
438 let session_cwd = resume_thread
439 .as_ref()
440 .and_then(|resume| {
441 resume
442 .cwd
443 .as_ref()
444 .and_then(|cwd| util::paths::normalize_lexically(cwd).ok())
445 .filter(|cwd| {
446 worktree_roots
447 .iter()
448 .any(|root| cwd.starts_with(root.as_ref()))
449 })
450 .map(|path| path.into())
451 })
452 .or_else(|| root_dir.clone())
453 .unwrap_or_else(|| paths::home_dir().as_path().into());
454
455 let (status_tx, mut status_rx) = watch::channel("Loading…".into());
456 let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
457 let delegate = AgentServerDelegate::new(
458 project.read(cx).agent_server_store().clone(),
459 project.clone(),
460 Some(status_tx),
461 Some(new_version_available_tx),
462 );
463
464 let connect_task = agent.connect(root_dir.as_deref(), delegate, cx);
465 let load_task = cx.spawn_in(window, async move |this, cx| {
466 let connection = match connect_task.await {
467 Ok((connection, login)) => {
468 this.update(cx, |this, _| this.login = login).ok();
469 connection
470 }
471 Err(err) => {
472 this.update_in(cx, |this, window, cx| {
473 if err.downcast_ref::<LoadError>().is_some() {
474 this.handle_load_error(err, window, cx);
475 } else if let Some(active) = this.active_thread() {
476 active.update(cx, |active, cx| active.handle_any_thread_error(err, cx));
477 }
478 cx.notify();
479 })
480 .log_err();
481 return;
482 }
483 };
484
485 telemetry::event!("Agent Thread Started", agent = connection.telemetry_id());
486
487 let mut resumed_without_history = false;
488 let result = if let Some(resume) = resume_thread.clone() {
489 cx.update(|_, cx| {
490 if connection.supports_load_session(cx) {
491 connection
492 .clone()
493 .load_session(resume, project.clone(), &session_cwd, cx)
494 } else if connection.supports_resume_session(cx) {
495 resumed_without_history = true;
496 connection
497 .clone()
498 .resume_session(resume, project.clone(), &session_cwd, cx)
499 } else {
500 Task::ready(Err(anyhow!(LoadError::Other(
501 "Loading or resuming sessions is not supported by this agent.".into()
502 ))))
503 }
504 })
505 .log_err()
506 } else {
507 cx.update(|_, cx| {
508 connection
509 .clone()
510 .new_session(project.clone(), session_cwd.as_ref(), cx)
511 })
512 .log_err()
513 };
514
515 let Some(result) = result else {
516 return;
517 };
518
519 let result = match result.await {
520 Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
521 Ok(err) => {
522 cx.update(|window, cx| {
523 Self::handle_auth_required(this, err, agent.name(), window, cx)
524 })
525 .log_err();
526 return;
527 }
528 Err(err) => Err(err),
529 },
530 Ok(thread) => Ok(thread),
531 };
532
533 this.update_in(cx, |this, window, cx| {
534 match result {
535 Ok(thread) => {
536 let current = this.new_thread_view(
537 None,
538 thread,
539 resumed_without_history,
540 resume_thread,
541 initial_content,
542 window,
543 cx,
544 );
545
546 if this.focus_handle.contains_focused(window, cx) {
547 current
548 .read(cx)
549 .message_editor
550 .focus_handle(cx)
551 .focus(window, cx);
552 }
553
554 this.set_server_state(
555 ServerState::Connected(ConnectedServerState {
556 connection,
557 auth_state: AuthState::Ok,
558 current: current.clone(),
559 threads: HashMap::from_iter([(
560 current.read(cx).thread.read(cx).session_id().clone(),
561 current,
562 )]),
563 }),
564 cx,
565 );
566 }
567 Err(err) => {
568 this.handle_load_error(err, window, cx);
569 }
570 };
571 })
572 .log_err();
573 });
574
575 cx.spawn(async move |this, cx| {
576 while let Ok(new_version) = new_version_available_rx.recv().await {
577 if let Some(new_version) = new_version {
578 this.update(cx, |this, cx| {
579 if let Some(thread) = this.active_thread() {
580 thread.update(cx, |thread, _cx| {
581 thread.new_server_version_available = Some(new_version.into());
582 });
583 }
584 cx.notify();
585 })
586 .ok();
587 }
588 }
589 })
590 .detach();
591
592 let loading_view = cx.new(|cx| {
593 let update_title_task = cx.spawn(async move |this, cx| {
594 loop {
595 let status = status_rx.recv().await?;
596 this.update(cx, |this: &mut LoadingView, cx| {
597 this.title = status;
598 cx.notify();
599 })?;
600 }
601 });
602
603 LoadingView {
604 title: "Loading…".into(),
605 _load_task: load_task,
606 _update_title_task: update_title_task,
607 }
608 });
609
610 ServerState::Loading(loading_view)
611 }
612
613 fn new_thread_view(
614 &self,
615 parent_id: Option<acp::SessionId>,
616 thread: Entity<AcpThread>,
617 resumed_without_history: bool,
618 resume_thread: Option<AgentSessionInfo>,
619 initial_content: Option<ExternalAgentInitialContent>,
620 window: &mut Window,
621 cx: &mut Context<Self>,
622 ) -> Entity<AcpThreadView> {
623 let agent_name = self.agent.name();
624 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
625 let available_commands = Rc::new(RefCell::new(vec![]));
626
627 let action_log = thread.read(cx).action_log().clone();
628
629 prompt_capabilities.replace(thread.read(cx).prompt_capabilities());
630
631 let entry_view_state = cx.new(|_| {
632 EntryViewState::new(
633 self.workspace.clone(),
634 self.project.downgrade(),
635 self.thread_store.clone(),
636 self.history.downgrade(),
637 self.prompt_store.clone(),
638 prompt_capabilities.clone(),
639 available_commands.clone(),
640 self.agent.name(),
641 )
642 });
643
644 let count = thread.read(cx).entries().len();
645 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
646 entry_view_state.update(cx, |view_state, cx| {
647 for ix in 0..count {
648 view_state.sync_entry(ix, &thread, window, cx);
649 }
650 list_state.splice_focusable(
651 0..0,
652 (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
653 );
654 });
655
656 AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
657
658 let connection = thread.read(cx).connection().clone();
659 let session_id = thread.read(cx).session_id().clone();
660 let session_list = if connection.supports_session_history(cx) {
661 connection.session_list(cx)
662 } else {
663 None
664 };
665 self.history.update(cx, |history, cx| {
666 history.set_session_list(session_list, cx);
667 });
668
669 // Check for config options first
670 // Config options take precedence over legacy mode/model selectors
671 // (feature flag gating happens at the data layer)
672 let config_options_provider = connection.session_config_options(&session_id, cx);
673
674 let config_options_view;
675 let mode_selector;
676 let model_selector;
677 if let Some(config_options) = config_options_provider {
678 // Use config options - don't create mode_selector or model_selector
679 let agent_server = self.agent.clone();
680 let fs = self.project.read(cx).fs().clone();
681 config_options_view =
682 Some(cx.new(|cx| {
683 ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
684 }));
685 model_selector = None;
686 mode_selector = None;
687 } else {
688 // Fall back to legacy mode/model selectors
689 config_options_view = None;
690 model_selector = connection.model_selector(&session_id).map(|selector| {
691 let agent_server = self.agent.clone();
692 let fs = self.project.read(cx).fs().clone();
693 cx.new(|cx| {
694 AcpModelSelectorPopover::new(
695 selector,
696 agent_server,
697 fs,
698 PopoverMenuHandle::default(),
699 self.focus_handle(cx),
700 window,
701 cx,
702 )
703 })
704 });
705
706 mode_selector = connection
707 .session_modes(&session_id, cx)
708 .map(|session_modes| {
709 let fs = self.project.read(cx).fs().clone();
710 let focus_handle = self.focus_handle(cx);
711 cx.new(|_cx| {
712 ModeSelector::new(session_modes, self.agent.clone(), fs, focus_handle)
713 })
714 });
715 }
716
717 let mut subscriptions = vec![
718 cx.subscribe_in(&thread, window, Self::handle_thread_event),
719 cx.observe(&action_log, |_, _, cx| cx.notify()),
720 ];
721
722 let parent_session_id = thread.read(cx).session_id().clone();
723 let subagent_sessions = thread
724 .read(cx)
725 .entries()
726 .iter()
727 .filter_map(|entry| match entry {
728 AgentThreadEntry::ToolCall(call) => call.subagent_session_id.clone(),
729 _ => None,
730 })
731 .collect::<Vec<_>>();
732
733 if !subagent_sessions.is_empty() {
734 cx.spawn_in(window, async move |this, cx| {
735 this.update_in(cx, |this, window, cx| {
736 for subagent_id in subagent_sessions {
737 this.load_subagent_session(
738 subagent_id,
739 parent_session_id.clone(),
740 window,
741 cx,
742 );
743 }
744 })
745 })
746 .detach();
747 }
748
749 let title_editor = if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
750 let editor = cx.new(|cx| {
751 let mut editor = Editor::single_line(window, cx);
752 editor.set_text(thread.read(cx).title(), window, cx);
753 editor
754 });
755 subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event));
756 Some(editor)
757 } else {
758 None
759 };
760
761 let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
762 connection.clone().downcast();
763 let profile_selector = profile_selector
764 .and_then(|native_connection| native_connection.thread(&session_id, cx))
765 .map(|native_thread| {
766 cx.new(|cx| {
767 ProfileSelector::new(
768 <dyn Fs>::global(cx),
769 Arc::new(native_thread),
770 self.focus_handle(cx),
771 cx,
772 )
773 })
774 });
775
776 let agent_display_name = self
777 .agent_server_store
778 .read(cx)
779 .agent_display_name(&ExternalAgentServerName(agent_name.clone()))
780 .unwrap_or_else(|| agent_name.clone());
781
782 let agent_icon = self.agent.logo();
783
784 let weak = cx.weak_entity();
785 cx.new(|cx| {
786 AcpThreadView::new(
787 parent_id,
788 thread,
789 self.login.clone(),
790 weak,
791 agent_icon,
792 agent_name,
793 agent_display_name,
794 self.workspace.clone(),
795 entry_view_state,
796 title_editor,
797 config_options_view,
798 mode_selector,
799 model_selector,
800 profile_selector,
801 list_state,
802 prompt_capabilities,
803 available_commands,
804 resumed_without_history,
805 resume_thread,
806 self.project.downgrade(),
807 self.thread_store.clone(),
808 self.history.clone(),
809 self.prompt_store.clone(),
810 initial_content,
811 subscriptions,
812 window,
813 cx,
814 )
815 })
816 }
817
818 fn handle_auth_required(
819 this: WeakEntity<Self>,
820 err: AuthRequired,
821 agent_name: SharedString,
822 window: &mut Window,
823 cx: &mut App,
824 ) {
825 let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
826 let registry = LanguageModelRegistry::global(cx);
827
828 let sub = window.subscribe(®istry, cx, {
829 let provider_id = provider_id.clone();
830 let this = this.clone();
831 move |_, ev, window, cx| {
832 if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
833 && &provider_id == updated_provider_id
834 && LanguageModelRegistry::global(cx)
835 .read(cx)
836 .provider(&provider_id)
837 .map_or(false, |provider| provider.is_authenticated(cx))
838 {
839 this.update(cx, |this, cx| {
840 this.reset(window, cx);
841 })
842 .ok();
843 }
844 }
845 });
846
847 let view = registry.read(cx).provider(&provider_id).map(|provider| {
848 provider.configuration_view(
849 language_model::ConfigurationViewTargetAgent::Other(agent_name),
850 window,
851 cx,
852 )
853 });
854
855 (view, Some(sub))
856 } else {
857 (None, None)
858 };
859
860 this.update(cx, |this, cx| {
861 if let Some(connected) = this.as_connected_mut() {
862 let description = err
863 .description
864 .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx)));
865
866 connected.auth_state = AuthState::Unauthenticated {
867 pending_auth_method: None,
868 configuration_view,
869 description,
870 _subscription: subscription,
871 };
872 if connected
873 .current
874 .read(cx)
875 .message_editor
876 .focus_handle(cx)
877 .is_focused(window)
878 {
879 this.focus_handle.focus(window, cx)
880 }
881 }
882 cx.notify();
883 })
884 .ok();
885 }
886
887 fn handle_load_error(
888 &mut self,
889 err: anyhow::Error,
890 window: &mut Window,
891 cx: &mut Context<Self>,
892 ) {
893 match &self.server_state {
894 ServerState::Connected(connected) => {
895 if connected
896 .current
897 .read(cx)
898 .message_editor
899 .focus_handle(cx)
900 .is_focused(window)
901 {
902 self.focus_handle.focus(window, cx)
903 }
904 }
905 _ => {}
906 }
907 let load_error = if let Some(load_err) = err.downcast_ref::<LoadError>() {
908 load_err.clone()
909 } else {
910 LoadError::Other(format!("{:#}", err).into())
911 };
912 self.emit_load_error_telemetry(&load_error);
913 self.set_server_state(ServerState::LoadError(load_error), cx);
914 }
915
916 fn handle_agent_servers_updated(
917 &mut self,
918 _agent_server_store: &Entity<project::AgentServerStore>,
919 _event: &project::AgentServersUpdated,
920 window: &mut Window,
921 cx: &mut Context<Self>,
922 ) {
923 // If we're in a LoadError state OR have a thread_error set (which can happen
924 // when agent.connect() fails during loading), retry loading the thread.
925 // This handles the case where a thread is restored before authentication completes.
926 let should_retry = match &self.server_state {
927 ServerState::Loading(_) => false,
928 ServerState::LoadError(_) => true,
929 ServerState::Connected(connected) => {
930 connected.auth_state.is_ok() && connected.has_thread_error(cx)
931 }
932 };
933
934 if should_retry {
935 if let Some(active) = self.active_thread() {
936 active.update(cx, |active, cx| {
937 active.clear_thread_error(cx);
938 });
939 }
940 self.reset(window, cx);
941 }
942 }
943
944 pub fn workspace(&self) -> &WeakEntity<Workspace> {
945 &self.workspace
946 }
947
948 pub fn title(&self, cx: &App) -> SharedString {
949 match &self.server_state {
950 ServerState::Connected(_) => "New Thread".into(),
951 ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(),
952 ServerState::LoadError(error) => match error {
953 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
954 LoadError::FailedToInstall(_) => {
955 format!("Failed to Install {}", self.agent.name()).into()
956 }
957 LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
958 LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
959 },
960 }
961 }
962
963 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
964 if let Some(active) = self.active_thread() {
965 active.update(cx, |active, cx| {
966 active.cancel_generation(cx);
967 });
968 }
969 }
970
971 pub fn handle_title_editor_event(
972 &mut self,
973 title_editor: &Entity<Editor>,
974 event: &EditorEvent,
975 window: &mut Window,
976 cx: &mut Context<Self>,
977 ) {
978 if let Some(active) = self.active_thread() {
979 active.update(cx, |active, cx| {
980 active.handle_title_editor_event(title_editor, event, window, cx);
981 });
982 }
983 }
984
985 pub fn is_loading(&self) -> bool {
986 matches!(self.server_state, ServerState::Loading { .. })
987 }
988
989 fn update_turn_tokens(&mut self, cx: &mut Context<Self>) {
990 if let Some(active) = self.active_thread() {
991 active.update(cx, |active, cx| {
992 active.update_turn_tokens(cx);
993 });
994 }
995 }
996
997 fn send_queued_message_at_index(
998 &mut self,
999 index: usize,
1000 is_send_now: bool,
1001 window: &mut Window,
1002 cx: &mut Context<Self>,
1003 ) {
1004 if let Some(active) = self.active_thread() {
1005 active.update(cx, |active, cx| {
1006 active.send_queued_message_at_index(index, is_send_now, window, cx);
1007 });
1008 }
1009 }
1010
1011 fn handle_thread_event(
1012 &mut self,
1013 thread: &Entity<AcpThread>,
1014 event: &AcpThreadEvent,
1015 window: &mut Window,
1016 cx: &mut Context<Self>,
1017 ) {
1018 let thread_id = thread.read(cx).session_id().clone();
1019 let is_subagent = thread.read(cx).parent_session_id().is_some();
1020 match event {
1021 AcpThreadEvent::NewEntry => {
1022 let len = thread.read(cx).entries().len();
1023 let index = len - 1;
1024 if let Some(active) = self.thread_view(&thread_id) {
1025 let entry_view_state = active.read(cx).entry_view_state.clone();
1026 let list_state = active.read(cx).list_state.clone();
1027 entry_view_state.update(cx, |view_state, cx| {
1028 view_state.sync_entry(index, thread, window, cx);
1029 list_state.splice_focusable(
1030 index..index,
1031 [view_state
1032 .entry(index)
1033 .and_then(|entry| entry.focus_handle(cx))],
1034 );
1035 });
1036 }
1037 }
1038 AcpThreadEvent::EntryUpdated(index) => {
1039 if let Some(entry_view_state) = self
1040 .thread_view(&thread_id)
1041 .map(|active| active.read(cx).entry_view_state.clone())
1042 {
1043 entry_view_state.update(cx, |view_state, cx| {
1044 view_state.sync_entry(*index, thread, window, cx)
1045 });
1046 }
1047 }
1048 AcpThreadEvent::EntriesRemoved(range) => {
1049 if let Some(active) = self.thread_view(&thread_id) {
1050 let entry_view_state = active.read(cx).entry_view_state.clone();
1051 let list_state = active.read(cx).list_state.clone();
1052 entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
1053 list_state.splice(range.clone(), 0);
1054 }
1055 }
1056 AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session(
1057 session_id.clone(),
1058 thread.read(cx).session_id().clone(),
1059 window,
1060 cx,
1061 ),
1062 AcpThreadEvent::ToolAuthorizationRequired => {
1063 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1064 }
1065 AcpThreadEvent::Retry(retry) => {
1066 if let Some(active) = self.thread_view(&thread_id) {
1067 active.update(cx, |active, _cx| {
1068 active.thread_retry_status = Some(retry.clone());
1069 });
1070 }
1071 }
1072 AcpThreadEvent::Stopped => {
1073 if let Some(active) = self.thread_view(&thread_id) {
1074 active.update(cx, |active, _cx| {
1075 active.thread_retry_status.take();
1076 });
1077 }
1078 if is_subagent {
1079 return;
1080 }
1081
1082 let used_tools = thread.read(cx).used_tools_since_last_user_message();
1083 self.notify_with_sound(
1084 if used_tools {
1085 "Finished running tools"
1086 } else {
1087 "New message"
1088 },
1089 IconName::ZedAssistant,
1090 window,
1091 cx,
1092 );
1093
1094 let should_send_queued = if let Some(active) = self.active_thread() {
1095 active.update(cx, |active, cx| {
1096 if active.skip_queue_processing_count > 0 {
1097 active.skip_queue_processing_count -= 1;
1098 false
1099 } else if active.user_interrupted_generation {
1100 // Manual interruption: don't auto-process queue.
1101 // Reset the flag so future completions can process normally.
1102 active.user_interrupted_generation = false;
1103 false
1104 } else {
1105 let has_queued = !active.local_queued_messages.is_empty();
1106 // Don't auto-send if the first message editor is currently focused
1107 let is_first_editor_focused = active
1108 .queued_message_editors
1109 .first()
1110 .is_some_and(|editor| editor.focus_handle(cx).is_focused(window));
1111 has_queued && !is_first_editor_focused
1112 }
1113 })
1114 } else {
1115 false
1116 };
1117 if should_send_queued {
1118 self.send_queued_message_at_index(0, false, window, cx);
1119 }
1120
1121 self.history.update(cx, |history, cx| history.refresh(cx));
1122 }
1123 AcpThreadEvent::Refusal => {
1124 let error = ThreadError::Refusal;
1125 if let Some(active) = self.thread_view(&thread_id) {
1126 active.update(cx, |active, cx| {
1127 active.handle_thread_error(error, cx);
1128 active.thread_retry_status.take();
1129 });
1130 }
1131 if !is_subagent {
1132 let model_or_agent_name = self.current_model_name(cx);
1133 let notification_message =
1134 format!("{} refused to respond to this request", model_or_agent_name);
1135 self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
1136 }
1137 }
1138 AcpThreadEvent::Error => {
1139 if let Some(active) = self.thread_view(&thread_id) {
1140 active.update(cx, |active, _cx| {
1141 active.thread_retry_status.take();
1142 });
1143 }
1144 if !is_subagent {
1145 self.notify_with_sound(
1146 "Agent stopped due to an error",
1147 IconName::Warning,
1148 window,
1149 cx,
1150 );
1151 }
1152 }
1153 AcpThreadEvent::LoadError(error) => {
1154 match &self.server_state {
1155 ServerState::Connected(connected) => {
1156 if connected
1157 .current
1158 .read(cx)
1159 .message_editor
1160 .focus_handle(cx)
1161 .is_focused(window)
1162 {
1163 self.focus_handle.focus(window, cx)
1164 }
1165 }
1166 _ => {}
1167 }
1168 self.set_server_state(ServerState::LoadError(error.clone()), cx);
1169 }
1170 AcpThreadEvent::TitleUpdated => {
1171 let title = thread.read(cx).title();
1172 if let Some(title_editor) = self
1173 .thread_view(&thread_id)
1174 .and_then(|active| active.read(cx).title_editor.clone())
1175 {
1176 title_editor.update(cx, |editor, cx| {
1177 if editor.text(cx) != title {
1178 editor.set_text(title, window, cx);
1179 }
1180 });
1181 }
1182 self.history.update(cx, |history, cx| history.refresh(cx));
1183 }
1184 AcpThreadEvent::PromptCapabilitiesUpdated => {
1185 if let Some(active) = self.thread_view(&thread_id) {
1186 active.update(cx, |active, _cx| {
1187 active
1188 .prompt_capabilities
1189 .replace(thread.read(_cx).prompt_capabilities());
1190 });
1191 }
1192 }
1193 AcpThreadEvent::TokenUsageUpdated => {
1194 self.update_turn_tokens(cx);
1195 self.emit_token_limit_telemetry_if_needed(thread, cx);
1196 }
1197 AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
1198 let mut available_commands = available_commands.clone();
1199
1200 if thread
1201 .read(cx)
1202 .connection()
1203 .auth_methods()
1204 .iter()
1205 .any(|method| method.id.0.as_ref() == "claude-login")
1206 {
1207 available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
1208 available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
1209 }
1210
1211 let has_commands = !available_commands.is_empty();
1212 if let Some(active) = self.active_thread() {
1213 active.update(cx, |active, _cx| {
1214 active.available_commands.replace(available_commands);
1215 });
1216 }
1217
1218 let agent_display_name = self
1219 .agent_server_store
1220 .read(cx)
1221 .agent_display_name(&ExternalAgentServerName(self.agent.name()))
1222 .unwrap_or_else(|| self.agent.name());
1223
1224 if let Some(active) = self.active_thread() {
1225 let new_placeholder =
1226 placeholder_text(agent_display_name.as_ref(), has_commands);
1227 active.update(cx, |active, cx| {
1228 active.message_editor.update(cx, |editor, cx| {
1229 editor.set_placeholder_text(&new_placeholder, window, cx);
1230 });
1231 });
1232 }
1233 }
1234 AcpThreadEvent::ModeUpdated(_mode) => {
1235 // The connection keeps track of the mode
1236 cx.notify();
1237 }
1238 AcpThreadEvent::ConfigOptionsUpdated(_) => {
1239 // The watch task in ConfigOptionsView handles rebuilding selectors
1240 cx.notify();
1241 }
1242 }
1243 cx.notify();
1244 }
1245
1246 fn authenticate(
1247 &mut self,
1248 method: acp::AuthMethodId,
1249 window: &mut Window,
1250 cx: &mut Context<Self>,
1251 ) {
1252 let Some(connected) = self.as_connected_mut() else {
1253 return;
1254 };
1255 let connection = connected.connection.clone();
1256
1257 let AuthState::Unauthenticated {
1258 configuration_view,
1259 pending_auth_method,
1260 ..
1261 } = &mut connected.auth_state
1262 else {
1263 return;
1264 };
1265
1266 let agent_telemetry_id = connection.telemetry_id();
1267
1268 // Check for the experimental "terminal-auth" _meta field
1269 let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
1270
1271 if let Some(terminal_auth) = auth_method
1272 .and_then(|a| a.meta.as_ref())
1273 .and_then(|m| m.get("terminal-auth"))
1274 {
1275 // Extract terminal auth details from meta
1276 if let (Some(command), Some(label)) = (
1277 terminal_auth.get("command").and_then(|v| v.as_str()),
1278 terminal_auth.get("label").and_then(|v| v.as_str()),
1279 ) {
1280 let args = terminal_auth
1281 .get("args")
1282 .and_then(|v| v.as_array())
1283 .map(|arr| {
1284 arr.iter()
1285 .filter_map(|v| v.as_str().map(String::from))
1286 .collect()
1287 })
1288 .unwrap_or_default();
1289
1290 let env = terminal_auth
1291 .get("env")
1292 .and_then(|v| v.as_object())
1293 .map(|obj| {
1294 obj.iter()
1295 .filter_map(|(k, v)| v.as_str().map(|val| (k.clone(), val.to_string())))
1296 .collect::<HashMap<String, String>>()
1297 })
1298 .unwrap_or_default();
1299
1300 // Run SpawnInTerminal in the same dir as the ACP server
1301 let cwd = connected
1302 .connection
1303 .clone()
1304 .downcast::<agent_servers::AcpConnection>()
1305 .map(|acp_conn| acp_conn.root_dir().to_path_buf());
1306
1307 // Build SpawnInTerminal from _meta
1308 let login = task::SpawnInTerminal {
1309 id: task::TaskId(format!("external-agent-{}-login", label)),
1310 full_label: label.to_string(),
1311 label: label.to_string(),
1312 command: Some(command.to_string()),
1313 args,
1314 command_label: label.to_string(),
1315 cwd,
1316 env,
1317 use_new_terminal: true,
1318 allow_concurrent_runs: true,
1319 hide: task::HideStrategy::Always,
1320 ..Default::default()
1321 };
1322
1323 configuration_view.take();
1324 pending_auth_method.replace(method.clone());
1325
1326 if let Some(workspace) = self.workspace.upgrade() {
1327 let project = self.project.clone();
1328 let authenticate = Self::spawn_external_agent_login(
1329 login,
1330 workspace,
1331 project,
1332 method.clone(),
1333 false,
1334 window,
1335 cx,
1336 );
1337 cx.notify();
1338 self.auth_task = Some(cx.spawn_in(window, {
1339 async move |this, cx| {
1340 let result = authenticate.await;
1341
1342 match &result {
1343 Ok(_) => telemetry::event!(
1344 "Authenticate Agent Succeeded",
1345 agent = agent_telemetry_id
1346 ),
1347 Err(_) => {
1348 telemetry::event!(
1349 "Authenticate Agent Failed",
1350 agent = agent_telemetry_id,
1351 )
1352 }
1353 }
1354
1355 this.update_in(cx, |this, window, cx| {
1356 if let Err(err) = result {
1357 if let Some(ConnectedServerState {
1358 auth_state:
1359 AuthState::Unauthenticated {
1360 pending_auth_method,
1361 ..
1362 },
1363 ..
1364 }) = this.as_connected_mut()
1365 {
1366 pending_auth_method.take();
1367 }
1368 if let Some(active) = this.active_thread() {
1369 active.update(cx, |active, cx| {
1370 active.handle_any_thread_error(err, cx);
1371 })
1372 }
1373 } else {
1374 this.reset(window, cx);
1375 }
1376 this.auth_task.take()
1377 })
1378 .ok();
1379 }
1380 }));
1381 }
1382 return;
1383 }
1384 }
1385
1386 if method.0.as_ref() == "gemini-api-key" {
1387 let registry = LanguageModelRegistry::global(cx);
1388 let provider = registry
1389 .read(cx)
1390 .provider(&language_model::GOOGLE_PROVIDER_ID)
1391 .unwrap();
1392 if !provider.is_authenticated(cx) {
1393 let this = cx.weak_entity();
1394 let agent_name = self.agent.name();
1395 window.defer(cx, |window, cx| {
1396 Self::handle_auth_required(
1397 this,
1398 AuthRequired {
1399 description: Some("GEMINI_API_KEY must be set".to_owned()),
1400 provider_id: Some(language_model::GOOGLE_PROVIDER_ID),
1401 },
1402 agent_name,
1403 window,
1404 cx,
1405 );
1406 });
1407 return;
1408 }
1409 } else if method.0.as_ref() == "vertex-ai"
1410 && std::env::var("GOOGLE_API_KEY").is_err()
1411 && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
1412 || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()))
1413 {
1414 let this = cx.weak_entity();
1415 let agent_name = self.agent.name();
1416
1417 window.defer(cx, |window, cx| {
1418 Self::handle_auth_required(
1419 this,
1420 AuthRequired {
1421 description: Some(
1422 "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed."
1423 .to_owned(),
1424 ),
1425 provider_id: None,
1426 },
1427 agent_name,
1428 window,
1429 cx,
1430 )
1431 });
1432 return;
1433 }
1434
1435 configuration_view.take();
1436 pending_auth_method.replace(method.clone());
1437 let authenticate = if let Some(login) = self.login.clone() {
1438 if let Some(workspace) = self.workspace.upgrade() {
1439 let project = self.project.clone();
1440 Self::spawn_external_agent_login(
1441 login,
1442 workspace,
1443 project,
1444 method.clone(),
1445 false,
1446 window,
1447 cx,
1448 )
1449 } else {
1450 Task::ready(Ok(()))
1451 }
1452 } else {
1453 connection.authenticate(method, cx)
1454 };
1455 cx.notify();
1456 self.auth_task = Some(cx.spawn_in(window, {
1457 async move |this, cx| {
1458 let result = authenticate.await;
1459
1460 match &result {
1461 Ok(_) => telemetry::event!(
1462 "Authenticate Agent Succeeded",
1463 agent = agent_telemetry_id
1464 ),
1465 Err(_) => {
1466 telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
1467 }
1468 }
1469
1470 this.update_in(cx, |this, window, cx| {
1471 if let Err(err) = result {
1472 if let Some(ConnectedServerState {
1473 auth_state:
1474 AuthState::Unauthenticated {
1475 pending_auth_method,
1476 ..
1477 },
1478 ..
1479 }) = this.as_connected_mut()
1480 {
1481 pending_auth_method.take();
1482 }
1483 if let Some(active) = this.active_thread() {
1484 active.update(cx, |active, cx| active.handle_any_thread_error(err, cx));
1485 }
1486 } else {
1487 this.reset(window, cx);
1488 }
1489 this.auth_task.take()
1490 })
1491 .ok();
1492 }
1493 }));
1494 }
1495
1496 fn load_subagent_session(
1497 &mut self,
1498 subagent_id: acp::SessionId,
1499 parent_id: acp::SessionId,
1500 window: &mut Window,
1501 cx: &mut Context<Self>,
1502 ) {
1503 let Some(connected) = self.as_connected() else {
1504 return;
1505 };
1506 if connected.threads.contains_key(&subagent_id)
1507 || !connected.connection.supports_load_session(cx)
1508 {
1509 return;
1510 }
1511 let root_dir = self
1512 .project
1513 .read(cx)
1514 .worktrees(cx)
1515 .filter_map(|worktree| {
1516 if worktree.read(cx).is_single_file() {
1517 Some(worktree.read(cx).abs_path().parent()?.into())
1518 } else {
1519 Some(worktree.read(cx).abs_path())
1520 }
1521 })
1522 .next();
1523 let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into());
1524
1525 let subagent_thread_task = connected.connection.clone().load_session(
1526 AgentSessionInfo::new(subagent_id.clone()),
1527 self.project.clone(),
1528 &cwd,
1529 cx,
1530 );
1531
1532 cx.spawn_in(window, async move |this, cx| {
1533 let subagent_thread = subagent_thread_task.await?;
1534 this.update_in(cx, |this, window, cx| {
1535 let view = this.new_thread_view(
1536 Some(parent_id),
1537 subagent_thread,
1538 false,
1539 None,
1540 None,
1541 window,
1542 cx,
1543 );
1544 let Some(connected) = this.as_connected_mut() else {
1545 return;
1546 };
1547 connected.threads.insert(subagent_id, view);
1548 })
1549 })
1550 .detach();
1551 }
1552
1553 fn spawn_external_agent_login(
1554 login: task::SpawnInTerminal,
1555 workspace: Entity<Workspace>,
1556 project: Entity<Project>,
1557 method: acp::AuthMethodId,
1558 previous_attempt: bool,
1559 window: &mut Window,
1560 cx: &mut App,
1561 ) -> Task<Result<()>> {
1562 let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
1563 return Task::ready(Ok(()));
1564 };
1565
1566 window.spawn(cx, async move |cx| {
1567 let mut task = login.clone();
1568 if let Some(cmd) = &task.command {
1569 // Have "node" command use Zed's managed Node runtime by default
1570 if cmd == "node" {
1571 let resolved_node_runtime = project
1572 .update(cx, |project, cx| {
1573 let agent_server_store = project.agent_server_store().clone();
1574 agent_server_store.update(cx, |store, cx| {
1575 store.node_runtime().map(|node_runtime| {
1576 cx.background_spawn(async move {
1577 node_runtime.binary_path().await
1578 })
1579 })
1580 })
1581 });
1582
1583 if let Some(resolve_task) = resolved_node_runtime {
1584 if let Ok(node_path) = resolve_task.await {
1585 task.command = Some(node_path.to_string_lossy().to_string());
1586 }
1587 }
1588 }
1589 }
1590 task.shell = task::Shell::WithArguments {
1591 program: task.command.take().expect("login command should be set"),
1592 args: std::mem::take(&mut task.args),
1593 title_override: None
1594 };
1595 task.full_label = task.label.clone();
1596 task.id = task::TaskId(format!("external-agent-{}-login", task.label));
1597 task.command_label = task.label.clone();
1598 task.use_new_terminal = true;
1599 task.allow_concurrent_runs = true;
1600 task.hide = task::HideStrategy::Always;
1601
1602 let terminal = terminal_panel
1603 .update_in(cx, |terminal_panel, window, cx| {
1604 terminal_panel.spawn_task(&task, window, cx)
1605 })?
1606 .await?;
1607
1608 let success_patterns = match method.0.as_ref() {
1609 "claude-login" | "spawn-gemini-cli" => vec![
1610 "Login successful".to_string(),
1611 "Type your message".to_string(),
1612 ],
1613 _ => Vec::new(),
1614 };
1615 if success_patterns.is_empty() {
1616 // No success patterns specified: wait for the process to exit and check exit code
1617 let exit_status = terminal
1618 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1619 .await;
1620
1621 match exit_status {
1622 Some(status) if status.success() => Ok(()),
1623 Some(status) => Err(anyhow!(
1624 "Login command failed with exit code: {:?}",
1625 status.code()
1626 )),
1627 None => Err(anyhow!("Login command terminated without exit status")),
1628 }
1629 } else {
1630 // Look for specific output patterns to detect successful login
1631 let mut exit_status = terminal
1632 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1633 .fuse();
1634
1635 let logged_in = cx
1636 .spawn({
1637 let terminal = terminal.clone();
1638 async move |cx| {
1639 loop {
1640 cx.background_executor().timer(Duration::from_secs(1)).await;
1641 let content =
1642 terminal.update(cx, |terminal, _cx| terminal.get_content())?;
1643 if success_patterns.iter().any(|pattern| content.contains(pattern))
1644 {
1645 return anyhow::Ok(());
1646 }
1647 }
1648 }
1649 })
1650 .fuse();
1651 futures::pin_mut!(logged_in);
1652 futures::select_biased! {
1653 result = logged_in => {
1654 if let Err(e) = result {
1655 log::error!("{e}");
1656 return Err(anyhow!("exited before logging in"));
1657 }
1658 }
1659 _ = exit_status => {
1660 if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server()) && login.label.contains("gemini") {
1661 return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), method, true, window, cx))?.await
1662 }
1663 return Err(anyhow!("exited before logging in"));
1664 }
1665 }
1666 terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
1667 Ok(())
1668 }
1669 })
1670 }
1671
1672 pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
1673 self.active_thread().is_some_and(|active| {
1674 active
1675 .read(cx)
1676 .thread
1677 .read(cx)
1678 .entries()
1679 .iter()
1680 .any(|entry| {
1681 matches!(
1682 entry,
1683 AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some()
1684 )
1685 })
1686 })
1687 }
1688
1689 fn render_auth_required_state(
1690 &self,
1691 connection: &Rc<dyn AgentConnection>,
1692 description: Option<&Entity<Markdown>>,
1693 configuration_view: Option<&AnyView>,
1694 pending_auth_method: Option<&acp::AuthMethodId>,
1695 window: &mut Window,
1696 cx: &Context<Self>,
1697 ) -> impl IntoElement {
1698 let auth_methods = connection.auth_methods();
1699
1700 let agent_display_name = self
1701 .agent_server_store
1702 .read(cx)
1703 .agent_display_name(&ExternalAgentServerName(self.agent.name()))
1704 .unwrap_or_else(|| self.agent.name());
1705
1706 let show_fallback_description = auth_methods.len() > 1
1707 && configuration_view.is_none()
1708 && description.is_none()
1709 && pending_auth_method.is_none();
1710
1711 let auth_buttons = || {
1712 h_flex().justify_end().flex_wrap().gap_1().children(
1713 connection
1714 .auth_methods()
1715 .iter()
1716 .enumerate()
1717 .rev()
1718 .map(|(ix, method)| {
1719 let (method_id, name) = if self.project.read(cx).is_via_remote_server()
1720 && method.id.0.as_ref() == "oauth-personal"
1721 && method.name == "Log in with Google"
1722 {
1723 ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
1724 } else {
1725 (method.id.0.clone(), method.name.clone())
1726 };
1727
1728 let agent_telemetry_id = connection.telemetry_id();
1729
1730 Button::new(method_id.clone(), name)
1731 .label_size(LabelSize::Small)
1732 .map(|this| {
1733 if ix == 0 {
1734 this.style(ButtonStyle::Tinted(TintColor::Accent))
1735 } else {
1736 this.style(ButtonStyle::Outlined)
1737 }
1738 })
1739 .when_some(method.description.clone(), |this, description| {
1740 this.tooltip(Tooltip::text(description))
1741 })
1742 .on_click({
1743 cx.listener(move |this, _, window, cx| {
1744 telemetry::event!(
1745 "Authenticate Agent Started",
1746 agent = agent_telemetry_id,
1747 method = method_id
1748 );
1749
1750 this.authenticate(
1751 acp::AuthMethodId::new(method_id.clone()),
1752 window,
1753 cx,
1754 )
1755 })
1756 })
1757 }),
1758 )
1759 };
1760
1761 if pending_auth_method.is_some() {
1762 return Callout::new()
1763 .icon(IconName::Info)
1764 .title(format!("Authenticating to {}…", agent_display_name))
1765 .actions_slot(
1766 Icon::new(IconName::ArrowCircle)
1767 .size(IconSize::Small)
1768 .color(Color::Muted)
1769 .with_rotate_animation(2)
1770 .into_any_element(),
1771 )
1772 .into_any_element();
1773 }
1774
1775 Callout::new()
1776 .icon(IconName::Info)
1777 .title(format!("Authenticate to {}", agent_display_name))
1778 .when(auth_methods.len() == 1, |this| {
1779 this.actions_slot(auth_buttons())
1780 })
1781 .description_slot(
1782 v_flex()
1783 .text_ui(cx)
1784 .map(|this| {
1785 if show_fallback_description {
1786 this.child(
1787 Label::new("Choose one of the following authentication options:")
1788 .size(LabelSize::Small)
1789 .color(Color::Muted),
1790 )
1791 } else {
1792 this.children(
1793 configuration_view
1794 .cloned()
1795 .map(|view| div().w_full().child(view)),
1796 )
1797 .children(description.map(|desc| {
1798 self.render_markdown(
1799 desc.clone(),
1800 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
1801 )
1802 }))
1803 }
1804 })
1805 .when(auth_methods.len() > 1, |this| {
1806 this.gap_1().child(auth_buttons())
1807 }),
1808 )
1809 .into_any_element()
1810 }
1811
1812 fn emit_token_limit_telemetry_if_needed(
1813 &mut self,
1814 thread: &Entity<AcpThread>,
1815 cx: &mut Context<Self>,
1816 ) {
1817 let Some(active_thread) = self.active_thread() else {
1818 return;
1819 };
1820
1821 let (ratio, agent_telemetry_id, session_id) = {
1822 let thread_data = thread.read(cx);
1823 let Some(token_usage) = thread_data.token_usage() else {
1824 return;
1825 };
1826 (
1827 token_usage.ratio(),
1828 thread_data.connection().telemetry_id(),
1829 thread_data.session_id().clone(),
1830 )
1831 };
1832
1833 let kind = match ratio {
1834 acp_thread::TokenUsageRatio::Normal => {
1835 active_thread.update(cx, |active, _cx| {
1836 active.last_token_limit_telemetry = None;
1837 });
1838 return;
1839 }
1840 acp_thread::TokenUsageRatio::Warning => "warning",
1841 acp_thread::TokenUsageRatio::Exceeded => "exceeded",
1842 };
1843
1844 let should_skip = active_thread
1845 .read(cx)
1846 .last_token_limit_telemetry
1847 .as_ref()
1848 .is_some_and(|last| *last >= ratio);
1849 if should_skip {
1850 return;
1851 }
1852
1853 active_thread.update(cx, |active, _cx| {
1854 active.last_token_limit_telemetry = Some(ratio);
1855 });
1856
1857 telemetry::event!(
1858 "Agent Token Limit Warning",
1859 agent = agent_telemetry_id,
1860 session_id = session_id,
1861 kind = kind,
1862 );
1863 }
1864
1865 fn emit_load_error_telemetry(&self, error: &LoadError) {
1866 let error_kind = match error {
1867 LoadError::Unsupported { .. } => "unsupported",
1868 LoadError::FailedToInstall(_) => "failed_to_install",
1869 LoadError::Exited { .. } => "exited",
1870 LoadError::Other(_) => "other",
1871 };
1872
1873 let agent_name = self.agent.name();
1874
1875 telemetry::event!(
1876 "Agent Panel Error Shown",
1877 agent = agent_name,
1878 kind = error_kind,
1879 message = error.to_string(),
1880 );
1881 }
1882
1883 fn render_load_error(
1884 &self,
1885 e: &LoadError,
1886 window: &mut Window,
1887 cx: &mut Context<Self>,
1888 ) -> AnyElement {
1889 let (title, message, action_slot): (_, SharedString, _) = match e {
1890 LoadError::Unsupported {
1891 command: path,
1892 current_version,
1893 minimum_version,
1894 } => {
1895 return self.render_unsupported(path, current_version, minimum_version, window, cx);
1896 }
1897 LoadError::FailedToInstall(msg) => (
1898 "Failed to Install",
1899 msg.into(),
1900 Some(self.create_copy_button(msg.to_string()).into_any_element()),
1901 ),
1902 LoadError::Exited { status } => (
1903 "Failed to Launch",
1904 format!("Server exited with status {status}").into(),
1905 None,
1906 ),
1907 LoadError::Other(msg) => (
1908 "Failed to Launch",
1909 msg.into(),
1910 Some(self.create_copy_button(msg.to_string()).into_any_element()),
1911 ),
1912 };
1913
1914 Callout::new()
1915 .severity(Severity::Error)
1916 .icon(IconName::XCircleFilled)
1917 .title(title)
1918 .description(message)
1919 .actions_slot(div().children(action_slot))
1920 .into_any_element()
1921 }
1922
1923 fn render_unsupported(
1924 &self,
1925 path: &SharedString,
1926 version: &SharedString,
1927 minimum_version: &SharedString,
1928 _window: &mut Window,
1929 cx: &mut Context<Self>,
1930 ) -> AnyElement {
1931 let (heading_label, description_label) = (
1932 format!("Upgrade {} to work with Zed", self.agent.name()),
1933 if version.is_empty() {
1934 format!(
1935 "Currently using {}, which does not report a valid --version",
1936 path,
1937 )
1938 } else {
1939 format!(
1940 "Currently using {}, which is only version {} (need at least {minimum_version})",
1941 path, version
1942 )
1943 },
1944 );
1945
1946 v_flex()
1947 .w_full()
1948 .p_3p5()
1949 .gap_2p5()
1950 .border_t_1()
1951 .border_color(cx.theme().colors().border)
1952 .bg(linear_gradient(
1953 180.,
1954 linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
1955 linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
1956 ))
1957 .child(
1958 v_flex().gap_0p5().child(Label::new(heading_label)).child(
1959 Label::new(description_label)
1960 .size(LabelSize::Small)
1961 .color(Color::Muted),
1962 ),
1963 )
1964 .into_any_element()
1965 }
1966
1967 pub(crate) fn as_native_connection(
1968 &self,
1969 cx: &App,
1970 ) -> Option<Rc<agent::NativeAgentConnection>> {
1971 let acp_thread = self.active_thread()?.read(cx).thread.read(cx);
1972 acp_thread.connection().clone().downcast()
1973 }
1974
1975 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
1976 let acp_thread = self.active_thread()?.read(cx).thread.read(cx);
1977 self.as_native_connection(cx)?
1978 .thread(acp_thread.session_id(), cx)
1979 }
1980
1981 fn queued_messages_len(&self, cx: &App) -> usize {
1982 self.active_thread()
1983 .map(|thread| thread.read(cx).local_queued_messages.len())
1984 .unwrap_or_default()
1985 }
1986
1987 fn update_queued_message(
1988 &mut self,
1989 index: usize,
1990 content: Vec<acp::ContentBlock>,
1991 tracked_buffers: Vec<Entity<Buffer>>,
1992 cx: &mut Context<Self>,
1993 ) -> bool {
1994 match self.active_thread() {
1995 Some(thread) => thread.update(cx, |thread, _cx| {
1996 if index < thread.local_queued_messages.len() {
1997 thread.local_queued_messages[index] = QueuedMessage {
1998 content,
1999 tracked_buffers,
2000 };
2001 true
2002 } else {
2003 false
2004 }
2005 }),
2006 None => false,
2007 }
2008 }
2009
2010 fn queued_message_contents(&self, cx: &App) -> Vec<Vec<acp::ContentBlock>> {
2011 match self.active_thread() {
2012 None => Vec::new(),
2013 Some(thread) => thread
2014 .read(cx)
2015 .local_queued_messages
2016 .iter()
2017 .map(|q| q.content.clone())
2018 .collect(),
2019 }
2020 }
2021
2022 fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context<Self>) {
2023 let editor = match self.active_thread() {
2024 Some(thread) => thread.read(cx).queued_message_editors.get(index).cloned(),
2025 None => None,
2026 };
2027 let Some(editor) = editor else {
2028 return;
2029 };
2030
2031 let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx));
2032
2033 cx.spawn(async move |this, cx| {
2034 let Ok((content, tracked_buffers)) = contents_task.await else {
2035 return Ok::<(), anyhow::Error>(());
2036 };
2037
2038 this.update(cx, |this, cx| {
2039 this.update_queued_message(index, content, tracked_buffers, cx);
2040 cx.notify();
2041 })?;
2042
2043 Ok(())
2044 })
2045 .detach_and_log_err(cx);
2046 }
2047
2048 fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2049 let needed_count = self.queued_messages_len(cx);
2050 let queued_messages = self.queued_message_contents(cx);
2051
2052 let agent_name = self.agent.name();
2053 let workspace = self.workspace.clone();
2054 let project = self.project.downgrade();
2055 let history = self.history.downgrade();
2056
2057 let Some(thread) = self.active_thread() else {
2058 return;
2059 };
2060 let prompt_capabilities = thread.read(cx).prompt_capabilities.clone();
2061 let available_commands = thread.read(cx).available_commands.clone();
2062
2063 let current_count = thread.read(cx).queued_message_editors.len();
2064 let last_synced = thread.read(cx).last_synced_queue_length;
2065
2066 if current_count == needed_count && needed_count == last_synced {
2067 return;
2068 }
2069
2070 if current_count > needed_count {
2071 thread.update(cx, |thread, _cx| {
2072 thread.queued_message_editors.truncate(needed_count);
2073 thread
2074 .queued_message_editor_subscriptions
2075 .truncate(needed_count);
2076 });
2077
2078 let editors = thread.read(cx).queued_message_editors.clone();
2079 for (index, editor) in editors.into_iter().enumerate() {
2080 if let Some(content) = queued_messages.get(index) {
2081 editor.update(cx, |editor, cx| {
2082 editor.set_message(content.clone(), window, cx);
2083 });
2084 }
2085 }
2086 }
2087
2088 while thread.read(cx).queued_message_editors.len() < needed_count {
2089 let index = thread.read(cx).queued_message_editors.len();
2090 let content = queued_messages.get(index).cloned().unwrap_or_default();
2091
2092 let editor = cx.new(|cx| {
2093 let mut editor = MessageEditor::new(
2094 workspace.clone(),
2095 project.clone(),
2096 None,
2097 history.clone(),
2098 None,
2099 prompt_capabilities.clone(),
2100 available_commands.clone(),
2101 agent_name.clone(),
2102 "",
2103 EditorMode::AutoHeight {
2104 min_lines: 1,
2105 max_lines: Some(10),
2106 },
2107 window,
2108 cx,
2109 );
2110 editor.set_message(content, window, cx);
2111 editor
2112 });
2113
2114 let subscription = cx.subscribe_in(
2115 &editor,
2116 window,
2117 move |this, _editor, event, window, cx| match event {
2118 MessageEditorEvent::LostFocus => {
2119 this.save_queued_message_at_index(index, cx);
2120 }
2121 MessageEditorEvent::Cancel => {
2122 window.focus(&this.focus_handle(cx), cx);
2123 }
2124 MessageEditorEvent::Send => {
2125 window.focus(&this.focus_handle(cx), cx);
2126 }
2127 MessageEditorEvent::SendImmediately => {
2128 this.send_queued_message_at_index(index, true, window, cx);
2129 }
2130 _ => {}
2131 },
2132 );
2133
2134 thread.update(cx, |thread, _cx| {
2135 thread.queued_message_editors.push(editor);
2136 thread
2137 .queued_message_editor_subscriptions
2138 .push(subscription);
2139 });
2140 }
2141
2142 if let Some(active) = self.active_thread() {
2143 active.update(cx, |active, _cx| {
2144 active.last_synced_queue_length = needed_count;
2145 });
2146 }
2147 }
2148
2149 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2150 let workspace = self.workspace.clone();
2151 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2152 crate::acp::thread_view::active_thread::open_link(text, &workspace, window, cx);
2153 })
2154 }
2155
2156 fn notify_with_sound(
2157 &mut self,
2158 caption: impl Into<SharedString>,
2159 icon: IconName,
2160 window: &mut Window,
2161 cx: &mut Context<Self>,
2162 ) {
2163 self.play_notification_sound(window, cx);
2164 self.show_notification(caption, icon, window, cx);
2165 }
2166
2167 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
2168 let settings = AgentSettings::get_global(cx);
2169 if settings.play_sound_when_agent_done && !window.is_window_active() {
2170 Audio::play_sound(Sound::AgentDone, cx);
2171 }
2172 }
2173
2174 fn show_notification(
2175 &mut self,
2176 caption: impl Into<SharedString>,
2177 icon: IconName,
2178 window: &mut Window,
2179 cx: &mut Context<Self>,
2180 ) {
2181 if !self.notifications.is_empty() {
2182 return;
2183 }
2184
2185 let settings = AgentSettings::get_global(cx);
2186
2187 let window_is_inactive = !window.is_window_active();
2188 let panel_is_hidden = self
2189 .workspace
2190 .upgrade()
2191 .map(|workspace| AgentPanel::is_hidden(&workspace, cx))
2192 .unwrap_or(true);
2193
2194 let should_notify = window_is_inactive || panel_is_hidden;
2195
2196 if !should_notify {
2197 return;
2198 }
2199
2200 // TODO: Change this once we have title summarization for external agents.
2201 let title = self.agent.name();
2202
2203 match settings.notify_when_agent_waiting {
2204 NotifyWhenAgentWaiting::PrimaryScreen => {
2205 if let Some(primary) = cx.primary_display() {
2206 self.pop_up(icon, caption.into(), title, window, primary, cx);
2207 }
2208 }
2209 NotifyWhenAgentWaiting::AllScreens => {
2210 let caption = caption.into();
2211 for screen in cx.displays() {
2212 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
2213 }
2214 }
2215 NotifyWhenAgentWaiting::Never => {
2216 // Don't show anything
2217 }
2218 }
2219 }
2220
2221 fn pop_up(
2222 &mut self,
2223 icon: IconName,
2224 caption: SharedString,
2225 title: SharedString,
2226 window: &mut Window,
2227 screen: Rc<dyn PlatformDisplay>,
2228 cx: &mut Context<Self>,
2229 ) {
2230 let options = AgentNotification::window_options(screen, cx);
2231
2232 let project_name = self.workspace.upgrade().and_then(|workspace| {
2233 workspace
2234 .read(cx)
2235 .project()
2236 .read(cx)
2237 .visible_worktrees(cx)
2238 .next()
2239 .map(|worktree| worktree.read(cx).root_name_str().to_string())
2240 });
2241
2242 if let Some(screen_window) = cx
2243 .open_window(options, |_window, cx| {
2244 cx.new(|_cx| {
2245 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
2246 })
2247 })
2248 .log_err()
2249 && let Some(pop_up) = screen_window.entity(cx).log_err()
2250 {
2251 self.notification_subscriptions
2252 .entry(screen_window)
2253 .or_insert_with(Vec::new)
2254 .push(cx.subscribe_in(&pop_up, window, {
2255 |this, _, event, window, cx| match event {
2256 AgentNotificationEvent::Accepted => {
2257 let handle = window.window_handle();
2258 cx.activate(true);
2259
2260 let workspace_handle = this.workspace.clone();
2261
2262 // If there are multiple Zed windows, activate the correct one.
2263 cx.defer(move |cx| {
2264 handle
2265 .update(cx, |_view, window, _cx| {
2266 window.activate_window();
2267
2268 if let Some(workspace) = workspace_handle.upgrade() {
2269 workspace.update(_cx, |workspace, cx| {
2270 workspace.focus_panel::<AgentPanel>(window, cx);
2271 });
2272 }
2273 })
2274 .log_err();
2275 });
2276
2277 this.dismiss_notifications(cx);
2278 }
2279 AgentNotificationEvent::Dismissed => {
2280 this.dismiss_notifications(cx);
2281 }
2282 }
2283 }));
2284
2285 self.notifications.push(screen_window);
2286
2287 // If the user manually refocuses the original window, dismiss the popup.
2288 self.notification_subscriptions
2289 .entry(screen_window)
2290 .or_insert_with(Vec::new)
2291 .push({
2292 let pop_up_weak = pop_up.downgrade();
2293
2294 cx.observe_window_activation(window, move |_, window, cx| {
2295 if window.is_window_active()
2296 && let Some(pop_up) = pop_up_weak.upgrade()
2297 {
2298 pop_up.update(cx, |_, cx| {
2299 cx.emit(AgentNotificationEvent::Dismissed);
2300 });
2301 }
2302 })
2303 });
2304 }
2305 }
2306
2307 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
2308 for window in self.notifications.drain(..) {
2309 window
2310 .update(cx, |_, window, _| {
2311 window.remove_window();
2312 })
2313 .ok();
2314
2315 self.notification_subscriptions.remove(&window);
2316 }
2317 }
2318
2319 fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2320 if let Some(entry_view_state) = self
2321 .active_thread()
2322 .map(|active| active.read(cx).entry_view_state.clone())
2323 {
2324 entry_view_state.update(cx, |entry_view_state, cx| {
2325 entry_view_state.agent_ui_font_size_changed(cx);
2326 });
2327 }
2328 }
2329
2330 pub(crate) fn insert_dragged_files(
2331 &self,
2332 paths: Vec<project::ProjectPath>,
2333 added_worktrees: Vec<Entity<project::Worktree>>,
2334 window: &mut Window,
2335 cx: &mut Context<Self>,
2336 ) {
2337 if let Some(active_thread) = self.active_thread() {
2338 active_thread.update(cx, |thread, cx| {
2339 thread.message_editor.update(cx, |editor, cx| {
2340 editor.insert_dragged_files(paths, added_worktrees, window, cx);
2341 })
2342 });
2343 }
2344 }
2345
2346 /// Inserts the selected text into the message editor or the message being
2347 /// edited, if any.
2348 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
2349 if let Some(active_thread) = self.active_thread() {
2350 active_thread.update(cx, |thread, cx| {
2351 thread.active_editor(cx).update(cx, |editor, cx| {
2352 editor.insert_selections(window, cx);
2353 })
2354 });
2355 }
2356 }
2357
2358 /// Inserts terminal text as a crease into the message editor.
2359 pub(crate) fn insert_terminal_text(
2360 &self,
2361 text: String,
2362 window: &mut Window,
2363 cx: &mut Context<Self>,
2364 ) {
2365 if let Some(active_thread) = self.active_thread() {
2366 active_thread.update(cx, |thread, cx| {
2367 thread.message_editor.update(cx, |editor, cx| {
2368 editor.insert_terminal_crease(text, window, cx);
2369 })
2370 });
2371 }
2372 }
2373
2374 fn current_model_name(&self, cx: &App) -> SharedString {
2375 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
2376 // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
2377 // This provides better clarity about what refused the request
2378 if self.as_native_connection(cx).is_some() {
2379 self.active_thread()
2380 .and_then(|active| active.read(cx).model_selector.clone())
2381 .and_then(|selector| selector.read(cx).active_model(cx))
2382 .map(|model| model.name.clone())
2383 .unwrap_or_else(|| SharedString::from("The model"))
2384 } else {
2385 // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
2386 self.agent.name()
2387 }
2388 }
2389
2390 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
2391 let message = message.into();
2392
2393 CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
2394 }
2395
2396 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2397 let agent_name = self.agent.name();
2398 if let Some(active) = self.active_thread() {
2399 active.update(cx, |active, cx| active.clear_thread_error(cx));
2400 }
2401 let this = cx.weak_entity();
2402 window.defer(cx, |window, cx| {
2403 Self::handle_auth_required(this, AuthRequired::new(), agent_name, window, cx);
2404 })
2405 }
2406
2407 pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context<Self>) {
2408 let task = self.history.update(cx, |history, cx| {
2409 history.delete_session(&entry.session_id, cx)
2410 });
2411 task.detach_and_log_err(cx);
2412 }
2413}
2414
2415fn loading_contents_spinner(size: IconSize) -> AnyElement {
2416 Icon::new(IconName::LoadCircle)
2417 .size(size)
2418 .color(Color::Accent)
2419 .with_rotate_animation(3)
2420 .into_any_element()
2421}
2422
2423fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
2424 if agent_name == "Zed Agent" {
2425 format!("Message the {} — @ to include context", agent_name)
2426 } else if has_commands {
2427 format!(
2428 "Message {} — @ to include context, / for commands",
2429 agent_name
2430 )
2431 } else {
2432 format!("Message {} — @ to include context", agent_name)
2433 }
2434}
2435
2436impl Focusable for AcpServerView {
2437 fn focus_handle(&self, cx: &App) -> FocusHandle {
2438 match self.active_thread() {
2439 Some(thread) => thread.read(cx).focus_handle(cx),
2440 None => self.focus_handle.clone(),
2441 }
2442 }
2443}
2444
2445#[cfg(any(test, feature = "test-support"))]
2446impl AcpServerView {
2447 /// Expands a tool call so its content is visible.
2448 /// This is primarily useful for visual testing.
2449 pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
2450 if let Some(active) = self.active_thread() {
2451 active.update(cx, |active, _cx| {
2452 active.expanded_tool_calls.insert(tool_call_id);
2453 });
2454 cx.notify();
2455 }
2456 }
2457
2458 /// Expands a subagent card so its content is visible.
2459 /// This is primarily useful for visual testing.
2460 pub fn expand_subagent(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
2461 if let Some(active) = self.active_thread() {
2462 active.update(cx, |active, _cx| {
2463 active.expanded_subagents.insert(session_id);
2464 });
2465 cx.notify();
2466 }
2467 }
2468}
2469
2470impl Render for AcpServerView {
2471 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2472 self.sync_queued_message_editors(window, cx);
2473
2474 v_flex()
2475 .track_focus(&self.focus_handle(cx))
2476 .size_full()
2477 .bg(cx.theme().colors().panel_background)
2478 .child(match &self.server_state {
2479 ServerState::Loading { .. } => v_flex()
2480 .flex_1()
2481 // .child(self.render_recent_history(cx))
2482 .into_any(),
2483 ServerState::LoadError(e) => v_flex()
2484 .flex_1()
2485 .size_full()
2486 .items_center()
2487 .justify_end()
2488 .child(self.render_load_error(e, window, cx))
2489 .into_any(),
2490 ServerState::Connected(ConnectedServerState {
2491 connection,
2492 auth_state:
2493 AuthState::Unauthenticated {
2494 description,
2495 configuration_view,
2496 pending_auth_method,
2497 _subscription,
2498 },
2499 ..
2500 }) => v_flex()
2501 .flex_1()
2502 .size_full()
2503 .justify_end()
2504 .child(self.render_auth_required_state(
2505 connection,
2506 description.as_ref(),
2507 configuration_view.as_ref(),
2508 pending_auth_method.as_ref(),
2509 window,
2510 cx,
2511 ))
2512 .into_any_element(),
2513 ServerState::Connected(connected) => connected.current.clone().into_any_element(),
2514 })
2515 }
2516}
2517
2518fn plan_label_markdown_style(
2519 status: &acp::PlanEntryStatus,
2520 window: &Window,
2521 cx: &App,
2522) -> MarkdownStyle {
2523 let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
2524
2525 MarkdownStyle {
2526 base_text_style: TextStyle {
2527 color: cx.theme().colors().text_muted,
2528 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2529 Some(gpui::StrikethroughStyle {
2530 thickness: px(1.),
2531 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2532 })
2533 } else {
2534 None
2535 },
2536 ..default_md_style.base_text_style
2537 },
2538 ..default_md_style
2539 }
2540}
2541
2542#[cfg(test)]
2543pub(crate) mod tests {
2544 use acp_thread::{
2545 AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
2546 };
2547 use action_log::ActionLog;
2548 use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
2549 use agent_client_protocol::SessionId;
2550 use editor::MultiBufferOffset;
2551 use fs::FakeFs;
2552 use gpui::{EventEmitter, TestAppContext, VisualTestContext};
2553 use parking_lot::Mutex;
2554 use project::Project;
2555 use serde_json::json;
2556 use settings::SettingsStore;
2557 use std::any::Any;
2558 use std::path::{Path, PathBuf};
2559 use std::rc::Rc;
2560 use std::sync::Arc;
2561 use workspace::Item;
2562
2563 use super::*;
2564
2565 #[gpui::test]
2566 async fn test_drop(cx: &mut TestAppContext) {
2567 init_test(cx);
2568
2569 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
2570 let weak_view = thread_view.downgrade();
2571 drop(thread_view);
2572 assert!(!weak_view.is_upgradable());
2573 }
2574
2575 #[gpui::test]
2576 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
2577 init_test(cx);
2578
2579 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
2580
2581 let message_editor = message_editor(&thread_view, cx);
2582 message_editor.update_in(cx, |editor, window, cx| {
2583 editor.set_text("Hello", window, cx);
2584 });
2585
2586 cx.deactivate_window();
2587
2588 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2589
2590 cx.run_until_parked();
2591
2592 assert!(
2593 cx.windows()
2594 .iter()
2595 .any(|window| window.downcast::<AgentNotification>().is_some())
2596 );
2597 }
2598
2599 #[gpui::test]
2600 async fn test_notification_for_error(cx: &mut TestAppContext) {
2601 init_test(cx);
2602
2603 let (thread_view, cx) =
2604 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
2605
2606 let message_editor = message_editor(&thread_view, cx);
2607 message_editor.update_in(cx, |editor, window, cx| {
2608 editor.set_text("Hello", window, cx);
2609 });
2610
2611 cx.deactivate_window();
2612
2613 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2614
2615 cx.run_until_parked();
2616
2617 assert!(
2618 cx.windows()
2619 .iter()
2620 .any(|window| window.downcast::<AgentNotification>().is_some())
2621 );
2622 }
2623
2624 #[gpui::test]
2625 async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
2626 init_test(cx);
2627
2628 let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
2629 let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
2630
2631 let fs = FakeFs::new(cx.executor());
2632 let project = Project::test(fs, [], cx).await;
2633 let (workspace, cx) =
2634 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2635
2636 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2637 // Create history without an initial session list - it will be set after connection
2638 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
2639
2640 let thread_view = cx.update(|window, cx| {
2641 cx.new(|cx| {
2642 AcpServerView::new(
2643 Rc::new(StubAgentServer::default_response()),
2644 None,
2645 None,
2646 workspace.downgrade(),
2647 project,
2648 Some(thread_store),
2649 None,
2650 history.clone(),
2651 window,
2652 cx,
2653 )
2654 })
2655 });
2656
2657 // Wait for connection to establish
2658 cx.run_until_parked();
2659
2660 // Initially empty because StubAgentConnection.session_list() returns None
2661 active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2662 assert_eq!(view.recent_history_entries.len(), 0);
2663 });
2664
2665 // Now set the session list - this simulates external agents providing their history
2666 let list_a: Rc<dyn AgentSessionList> =
2667 Rc::new(StubSessionList::new(vec![session_a.clone()]));
2668 history.update(cx, |history, cx| {
2669 history.set_session_list(Some(list_a), cx);
2670 });
2671 cx.run_until_parked();
2672
2673 active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2674 assert_eq!(view.recent_history_entries.len(), 1);
2675 assert_eq!(
2676 view.recent_history_entries[0].session_id,
2677 session_a.session_id
2678 );
2679 });
2680
2681 // Update to a different session list
2682 let list_b: Rc<dyn AgentSessionList> =
2683 Rc::new(StubSessionList::new(vec![session_b.clone()]));
2684 history.update(cx, |history, cx| {
2685 history.set_session_list(Some(list_b), cx);
2686 });
2687 cx.run_until_parked();
2688
2689 active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2690 assert_eq!(view.recent_history_entries.len(), 1);
2691 assert_eq!(
2692 view.recent_history_entries[0].session_id,
2693 session_b.session_id
2694 );
2695 });
2696 }
2697
2698 #[gpui::test]
2699 async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
2700 init_test(cx);
2701
2702 let session = AgentSessionInfo::new(SessionId::new("resume-session"));
2703 let fs = FakeFs::new(cx.executor());
2704 let project = Project::test(fs, [], cx).await;
2705 let (workspace, cx) =
2706 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2707
2708 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2709 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
2710
2711 let thread_view = cx.update(|window, cx| {
2712 cx.new(|cx| {
2713 AcpServerView::new(
2714 Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
2715 Some(session),
2716 None,
2717 workspace.downgrade(),
2718 project,
2719 Some(thread_store),
2720 None,
2721 history,
2722 window,
2723 cx,
2724 )
2725 })
2726 });
2727
2728 cx.run_until_parked();
2729
2730 thread_view.read_with(cx, |view, cx| {
2731 let state = view.active_thread().unwrap();
2732 assert!(state.read(cx).resumed_without_history);
2733 assert_eq!(state.read(cx).list_state.item_count(), 0);
2734 });
2735 }
2736
2737 #[gpui::test]
2738 async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
2739 init_test(cx);
2740
2741 let fs = FakeFs::new(cx.executor());
2742 fs.insert_tree(
2743 "/project",
2744 json!({
2745 "subdir": {
2746 "file.txt": "hello"
2747 }
2748 }),
2749 )
2750 .await;
2751 let project = Project::test(fs, [Path::new("/project")], cx).await;
2752 let (workspace, cx) =
2753 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2754
2755 let connection = CwdCapturingConnection::new();
2756 let captured_cwd = connection.captured_cwd.clone();
2757
2758 let mut session = AgentSessionInfo::new(SessionId::new("session-1"));
2759 session.cwd = Some(PathBuf::from("/project/subdir"));
2760
2761 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2762 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
2763
2764 let _thread_view = cx.update(|window, cx| {
2765 cx.new(|cx| {
2766 AcpServerView::new(
2767 Rc::new(StubAgentServer::new(connection)),
2768 Some(session),
2769 None,
2770 workspace.downgrade(),
2771 project,
2772 Some(thread_store),
2773 None,
2774 history,
2775 window,
2776 cx,
2777 )
2778 })
2779 });
2780
2781 cx.run_until_parked();
2782
2783 assert_eq!(
2784 captured_cwd.lock().as_deref(),
2785 Some(Path::new("/project/subdir")),
2786 "Should use session cwd when it's inside the project"
2787 );
2788 }
2789
2790 #[gpui::test]
2791 async fn test_resume_thread_uses_fallback_cwd_when_outside_project(cx: &mut TestAppContext) {
2792 init_test(cx);
2793
2794 let fs = FakeFs::new(cx.executor());
2795 fs.insert_tree(
2796 "/project",
2797 json!({
2798 "file.txt": "hello"
2799 }),
2800 )
2801 .await;
2802 let project = Project::test(fs, [Path::new("/project")], cx).await;
2803 let (workspace, cx) =
2804 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2805
2806 let connection = CwdCapturingConnection::new();
2807 let captured_cwd = connection.captured_cwd.clone();
2808
2809 let mut session = AgentSessionInfo::new(SessionId::new("session-1"));
2810 session.cwd = Some(PathBuf::from("/some/other/path"));
2811
2812 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2813 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
2814
2815 let _thread_view = cx.update(|window, cx| {
2816 cx.new(|cx| {
2817 AcpServerView::new(
2818 Rc::new(StubAgentServer::new(connection)),
2819 Some(session),
2820 None,
2821 workspace.downgrade(),
2822 project,
2823 Some(thread_store),
2824 None,
2825 history,
2826 window,
2827 cx,
2828 )
2829 })
2830 });
2831
2832 cx.run_until_parked();
2833
2834 assert_eq!(
2835 captured_cwd.lock().as_deref(),
2836 Some(Path::new("/project")),
2837 "Should use fallback project cwd when session cwd is outside the project"
2838 );
2839 }
2840
2841 #[gpui::test]
2842 async fn test_resume_thread_rejects_unnormalized_cwd_outside_project(cx: &mut TestAppContext) {
2843 init_test(cx);
2844
2845 let fs = FakeFs::new(cx.executor());
2846 fs.insert_tree(
2847 "/project",
2848 json!({
2849 "file.txt": "hello"
2850 }),
2851 )
2852 .await;
2853 let project = Project::test(fs, [Path::new("/project")], cx).await;
2854 let (workspace, cx) =
2855 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2856
2857 let connection = CwdCapturingConnection::new();
2858 let captured_cwd = connection.captured_cwd.clone();
2859
2860 let mut session = AgentSessionInfo::new(SessionId::new("session-1"));
2861 session.cwd = Some(PathBuf::from("/project/../outside"));
2862
2863 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2864 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
2865
2866 let _thread_view = cx.update(|window, cx| {
2867 cx.new(|cx| {
2868 AcpServerView::new(
2869 Rc::new(StubAgentServer::new(connection)),
2870 Some(session),
2871 None,
2872 workspace.downgrade(),
2873 project,
2874 Some(thread_store),
2875 None,
2876 history,
2877 window,
2878 cx,
2879 )
2880 })
2881 });
2882
2883 cx.run_until_parked();
2884
2885 assert_eq!(
2886 captured_cwd.lock().as_deref(),
2887 Some(Path::new("/project")),
2888 "Should reject unnormalized cwd that resolves outside the project and use fallback cwd"
2889 );
2890 }
2891
2892 #[gpui::test]
2893 async fn test_refusal_handling(cx: &mut TestAppContext) {
2894 init_test(cx);
2895
2896 let (thread_view, cx) =
2897 setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
2898
2899 let message_editor = message_editor(&thread_view, cx);
2900 message_editor.update_in(cx, |editor, window, cx| {
2901 editor.set_text("Do something harmful", window, cx);
2902 });
2903
2904 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2905
2906 cx.run_until_parked();
2907
2908 // Check that the refusal error is set
2909 thread_view.read_with(cx, |thread_view, cx| {
2910 let state = thread_view.active_thread().unwrap();
2911 assert!(
2912 matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
2913 "Expected refusal error to be set"
2914 );
2915 });
2916 }
2917
2918 #[gpui::test]
2919 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
2920 init_test(cx);
2921
2922 let tool_call_id = acp::ToolCallId::new("1");
2923 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
2924 .kind(acp::ToolKind::Edit)
2925 .content(vec!["hi".into()]);
2926 let connection =
2927 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
2928 tool_call_id,
2929 PermissionOptions::Flat(vec![acp::PermissionOption::new(
2930 "1",
2931 "Allow",
2932 acp::PermissionOptionKind::AllowOnce,
2933 )]),
2934 )]));
2935
2936 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
2937
2938 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
2939
2940 let message_editor = message_editor(&thread_view, cx);
2941 message_editor.update_in(cx, |editor, window, cx| {
2942 editor.set_text("Hello", window, cx);
2943 });
2944
2945 cx.deactivate_window();
2946
2947 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2948
2949 cx.run_until_parked();
2950
2951 assert!(
2952 cx.windows()
2953 .iter()
2954 .any(|window| window.downcast::<AgentNotification>().is_some())
2955 );
2956 }
2957
2958 #[gpui::test]
2959 async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
2960 init_test(cx);
2961
2962 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
2963
2964 add_to_workspace(thread_view.clone(), cx);
2965
2966 let message_editor = message_editor(&thread_view, cx);
2967
2968 message_editor.update_in(cx, |editor, window, cx| {
2969 editor.set_text("Hello", window, cx);
2970 });
2971
2972 // Window is active (don't deactivate), but panel will be hidden
2973 // Note: In the test environment, the panel is not actually added to the dock,
2974 // so is_agent_panel_hidden will return true
2975
2976 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2977
2978 cx.run_until_parked();
2979
2980 // Should show notification because window is active but panel is hidden
2981 assert!(
2982 cx.windows()
2983 .iter()
2984 .any(|window| window.downcast::<AgentNotification>().is_some()),
2985 "Expected notification when panel is hidden"
2986 );
2987 }
2988
2989 #[gpui::test]
2990 async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
2991 init_test(cx);
2992
2993 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
2994
2995 let message_editor = message_editor(&thread_view, cx);
2996 message_editor.update_in(cx, |editor, window, cx| {
2997 editor.set_text("Hello", window, cx);
2998 });
2999
3000 // Deactivate window - should show notification regardless of setting
3001 cx.deactivate_window();
3002
3003 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3004
3005 cx.run_until_parked();
3006
3007 // Should still show notification when window is inactive (existing behavior)
3008 assert!(
3009 cx.windows()
3010 .iter()
3011 .any(|window| window.downcast::<AgentNotification>().is_some()),
3012 "Expected notification when window is inactive"
3013 );
3014 }
3015
3016 #[gpui::test]
3017 async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
3018 init_test(cx);
3019
3020 // Set notify_when_agent_waiting to Never
3021 cx.update(|cx| {
3022 AgentSettings::override_global(
3023 AgentSettings {
3024 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
3025 ..AgentSettings::get_global(cx).clone()
3026 },
3027 cx,
3028 );
3029 });
3030
3031 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3032
3033 let message_editor = message_editor(&thread_view, cx);
3034 message_editor.update_in(cx, |editor, window, cx| {
3035 editor.set_text("Hello", window, cx);
3036 });
3037
3038 // Window is active
3039
3040 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3041
3042 cx.run_until_parked();
3043
3044 // Should NOT show notification because notify_when_agent_waiting is Never
3045 assert!(
3046 !cx.windows()
3047 .iter()
3048 .any(|window| window.downcast::<AgentNotification>().is_some()),
3049 "Expected no notification when notify_when_agent_waiting is Never"
3050 );
3051 }
3052
3053 #[gpui::test]
3054 async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
3055 init_test(cx);
3056
3057 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3058
3059 let weak_view = thread_view.downgrade();
3060
3061 let message_editor = message_editor(&thread_view, cx);
3062 message_editor.update_in(cx, |editor, window, cx| {
3063 editor.set_text("Hello", window, cx);
3064 });
3065
3066 cx.deactivate_window();
3067
3068 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3069
3070 cx.run_until_parked();
3071
3072 // Verify notification is shown
3073 assert!(
3074 cx.windows()
3075 .iter()
3076 .any(|window| window.downcast::<AgentNotification>().is_some()),
3077 "Expected notification to be shown"
3078 );
3079
3080 // Drop the thread view (simulating navigation to a new thread)
3081 drop(thread_view);
3082 drop(message_editor);
3083 // Trigger an update to flush effects, which will call release_dropped_entities
3084 cx.update(|_window, _cx| {});
3085 cx.run_until_parked();
3086
3087 // Verify the entity was actually released
3088 assert!(
3089 !weak_view.is_upgradable(),
3090 "Thread view entity should be released after dropping"
3091 );
3092
3093 // The notification should be automatically closed via on_release
3094 assert!(
3095 !cx.windows()
3096 .iter()
3097 .any(|window| window.downcast::<AgentNotification>().is_some()),
3098 "Notification should be closed when thread view is dropped"
3099 );
3100 }
3101
3102 async fn setup_thread_view(
3103 agent: impl AgentServer + 'static,
3104 cx: &mut TestAppContext,
3105 ) -> (Entity<AcpServerView>, &mut VisualTestContext) {
3106 let fs = FakeFs::new(cx.executor());
3107 let project = Project::test(fs, [], cx).await;
3108 let (workspace, cx) =
3109 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3110
3111 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3112 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
3113
3114 let thread_view = cx.update(|window, cx| {
3115 cx.new(|cx| {
3116 AcpServerView::new(
3117 Rc::new(agent),
3118 None,
3119 None,
3120 workspace.downgrade(),
3121 project,
3122 Some(thread_store),
3123 None,
3124 history,
3125 window,
3126 cx,
3127 )
3128 })
3129 });
3130 cx.run_until_parked();
3131 (thread_view, cx)
3132 }
3133
3134 fn add_to_workspace(thread_view: Entity<AcpServerView>, cx: &mut VisualTestContext) {
3135 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3136
3137 workspace
3138 .update_in(cx, |workspace, window, cx| {
3139 workspace.add_item_to_active_pane(
3140 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
3141 None,
3142 true,
3143 window,
3144 cx,
3145 );
3146 })
3147 .unwrap();
3148 }
3149
3150 struct ThreadViewItem(Entity<AcpServerView>);
3151
3152 impl Item for ThreadViewItem {
3153 type Event = ();
3154
3155 fn include_in_nav_history() -> bool {
3156 false
3157 }
3158
3159 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3160 "Test".into()
3161 }
3162 }
3163
3164 impl EventEmitter<()> for ThreadViewItem {}
3165
3166 impl Focusable for ThreadViewItem {
3167 fn focus_handle(&self, cx: &App) -> FocusHandle {
3168 self.0.read(cx).focus_handle(cx)
3169 }
3170 }
3171
3172 impl Render for ThreadViewItem {
3173 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3174 self.0.clone().into_any_element()
3175 }
3176 }
3177
3178 struct StubAgentServer<C> {
3179 connection: C,
3180 }
3181
3182 impl<C> StubAgentServer<C> {
3183 fn new(connection: C) -> Self {
3184 Self { connection }
3185 }
3186 }
3187
3188 impl StubAgentServer<StubAgentConnection> {
3189 fn default_response() -> Self {
3190 let conn = StubAgentConnection::new();
3191 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3192 acp::ContentChunk::new("Default response".into()),
3193 )]);
3194 Self::new(conn)
3195 }
3196 }
3197
3198 impl<C> AgentServer for StubAgentServer<C>
3199 where
3200 C: 'static + AgentConnection + Send + Clone,
3201 {
3202 fn logo(&self) -> ui::IconName {
3203 ui::IconName::Ai
3204 }
3205
3206 fn name(&self) -> SharedString {
3207 "Test".into()
3208 }
3209
3210 fn connect(
3211 &self,
3212 _root_dir: Option<&Path>,
3213 _delegate: AgentServerDelegate,
3214 _cx: &mut App,
3215 ) -> Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
3216 Task::ready(Ok((Rc::new(self.connection.clone()), None)))
3217 }
3218
3219 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3220 self
3221 }
3222 }
3223
3224 #[derive(Clone)]
3225 struct StubSessionList {
3226 sessions: Vec<AgentSessionInfo>,
3227 }
3228
3229 impl StubSessionList {
3230 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3231 Self { sessions }
3232 }
3233 }
3234
3235 impl AgentSessionList for StubSessionList {
3236 fn list_sessions(
3237 &self,
3238 _request: AgentSessionListRequest,
3239 _cx: &mut App,
3240 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
3241 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
3242 }
3243 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3244 self
3245 }
3246 }
3247
3248 #[derive(Clone)]
3249 struct ResumeOnlyAgentConnection;
3250
3251 impl AgentConnection for ResumeOnlyAgentConnection {
3252 fn telemetry_id(&self) -> SharedString {
3253 "resume-only".into()
3254 }
3255
3256 fn new_session(
3257 self: Rc<Self>,
3258 project: Entity<Project>,
3259 _cwd: &Path,
3260 cx: &mut gpui::App,
3261 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3262 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3263 let thread = cx.new(|cx| {
3264 AcpThread::new(
3265 None,
3266 "ResumeOnlyAgentConnection",
3267 self.clone(),
3268 project,
3269 action_log,
3270 SessionId::new("new-session"),
3271 watch::Receiver::constant(
3272 acp::PromptCapabilities::new()
3273 .image(true)
3274 .audio(true)
3275 .embedded_context(true),
3276 ),
3277 cx,
3278 )
3279 });
3280 Task::ready(Ok(thread))
3281 }
3282
3283 fn supports_resume_session(&self, _cx: &App) -> bool {
3284 true
3285 }
3286
3287 fn resume_session(
3288 self: Rc<Self>,
3289 session: AgentSessionInfo,
3290 project: Entity<Project>,
3291 _cwd: &Path,
3292 cx: &mut App,
3293 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3294 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3295 let thread = cx.new(|cx| {
3296 AcpThread::new(
3297 None,
3298 "ResumeOnlyAgentConnection",
3299 self.clone(),
3300 project,
3301 action_log,
3302 session.session_id,
3303 watch::Receiver::constant(
3304 acp::PromptCapabilities::new()
3305 .image(true)
3306 .audio(true)
3307 .embedded_context(true),
3308 ),
3309 cx,
3310 )
3311 });
3312 Task::ready(Ok(thread))
3313 }
3314
3315 fn auth_methods(&self) -> &[acp::AuthMethod] {
3316 &[]
3317 }
3318
3319 fn authenticate(
3320 &self,
3321 _method_id: acp::AuthMethodId,
3322 _cx: &mut App,
3323 ) -> Task<gpui::Result<()>> {
3324 Task::ready(Ok(()))
3325 }
3326
3327 fn prompt(
3328 &self,
3329 _id: Option<acp_thread::UserMessageId>,
3330 _params: acp::PromptRequest,
3331 _cx: &mut App,
3332 ) -> Task<gpui::Result<acp::PromptResponse>> {
3333 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3334 }
3335
3336 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3337
3338 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3339 self
3340 }
3341 }
3342
3343 #[derive(Clone)]
3344 struct SaboteurAgentConnection;
3345
3346 impl AgentConnection for SaboteurAgentConnection {
3347 fn telemetry_id(&self) -> SharedString {
3348 "saboteur".into()
3349 }
3350
3351 fn new_session(
3352 self: Rc<Self>,
3353 project: Entity<Project>,
3354 _cwd: &Path,
3355 cx: &mut gpui::App,
3356 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3357 Task::ready(Ok(cx.new(|cx| {
3358 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3359 AcpThread::new(
3360 None,
3361 "SaboteurAgentConnection",
3362 self,
3363 project,
3364 action_log,
3365 SessionId::new("test"),
3366 watch::Receiver::constant(
3367 acp::PromptCapabilities::new()
3368 .image(true)
3369 .audio(true)
3370 .embedded_context(true),
3371 ),
3372 cx,
3373 )
3374 })))
3375 }
3376
3377 fn auth_methods(&self) -> &[acp::AuthMethod] {
3378 &[]
3379 }
3380
3381 fn authenticate(
3382 &self,
3383 _method_id: acp::AuthMethodId,
3384 _cx: &mut App,
3385 ) -> Task<gpui::Result<()>> {
3386 unimplemented!()
3387 }
3388
3389 fn prompt(
3390 &self,
3391 _id: Option<acp_thread::UserMessageId>,
3392 _params: acp::PromptRequest,
3393 _cx: &mut App,
3394 ) -> Task<gpui::Result<acp::PromptResponse>> {
3395 Task::ready(Err(anyhow::anyhow!("Error prompting")))
3396 }
3397
3398 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3399 unimplemented!()
3400 }
3401
3402 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3403 self
3404 }
3405 }
3406
3407 /// Simulates a model which always returns a refusal response
3408 #[derive(Clone)]
3409 struct RefusalAgentConnection;
3410
3411 impl AgentConnection for RefusalAgentConnection {
3412 fn telemetry_id(&self) -> SharedString {
3413 "refusal".into()
3414 }
3415
3416 fn new_session(
3417 self: Rc<Self>,
3418 project: Entity<Project>,
3419 _cwd: &Path,
3420 cx: &mut gpui::App,
3421 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3422 Task::ready(Ok(cx.new(|cx| {
3423 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3424 AcpThread::new(
3425 None,
3426 "RefusalAgentConnection",
3427 self,
3428 project,
3429 action_log,
3430 SessionId::new("test"),
3431 watch::Receiver::constant(
3432 acp::PromptCapabilities::new()
3433 .image(true)
3434 .audio(true)
3435 .embedded_context(true),
3436 ),
3437 cx,
3438 )
3439 })))
3440 }
3441
3442 fn auth_methods(&self) -> &[acp::AuthMethod] {
3443 &[]
3444 }
3445
3446 fn authenticate(
3447 &self,
3448 _method_id: acp::AuthMethodId,
3449 _cx: &mut App,
3450 ) -> Task<gpui::Result<()>> {
3451 unimplemented!()
3452 }
3453
3454 fn prompt(
3455 &self,
3456 _id: Option<acp_thread::UserMessageId>,
3457 _params: acp::PromptRequest,
3458 _cx: &mut App,
3459 ) -> Task<gpui::Result<acp::PromptResponse>> {
3460 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
3461 }
3462
3463 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3464 unimplemented!()
3465 }
3466
3467 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3468 self
3469 }
3470 }
3471
3472 #[derive(Clone)]
3473 struct CwdCapturingConnection {
3474 captured_cwd: Arc<Mutex<Option<PathBuf>>>,
3475 }
3476
3477 impl CwdCapturingConnection {
3478 fn new() -> Self {
3479 Self {
3480 captured_cwd: Arc::new(Mutex::new(None)),
3481 }
3482 }
3483 }
3484
3485 impl AgentConnection for CwdCapturingConnection {
3486 fn telemetry_id(&self) -> SharedString {
3487 "cwd-capturing".into()
3488 }
3489
3490 fn new_session(
3491 self: Rc<Self>,
3492 project: Entity<Project>,
3493 cwd: &Path,
3494 cx: &mut gpui::App,
3495 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3496 *self.captured_cwd.lock() = Some(cwd.to_path_buf());
3497 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3498 let thread = cx.new(|cx| {
3499 AcpThread::new(
3500 None,
3501 "CwdCapturingConnection",
3502 self.clone(),
3503 project,
3504 action_log,
3505 SessionId::new("new-session"),
3506 watch::Receiver::constant(
3507 acp::PromptCapabilities::new()
3508 .image(true)
3509 .audio(true)
3510 .embedded_context(true),
3511 ),
3512 cx,
3513 )
3514 });
3515 Task::ready(Ok(thread))
3516 }
3517
3518 fn supports_load_session(&self, _cx: &App) -> bool {
3519 true
3520 }
3521
3522 fn load_session(
3523 self: Rc<Self>,
3524 session: AgentSessionInfo,
3525 project: Entity<Project>,
3526 cwd: &Path,
3527 cx: &mut App,
3528 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3529 *self.captured_cwd.lock() = Some(cwd.to_path_buf());
3530 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3531 let thread = cx.new(|cx| {
3532 AcpThread::new(
3533 None,
3534 "CwdCapturingConnection",
3535 self.clone(),
3536 project,
3537 action_log,
3538 session.session_id,
3539 watch::Receiver::constant(
3540 acp::PromptCapabilities::new()
3541 .image(true)
3542 .audio(true)
3543 .embedded_context(true),
3544 ),
3545 cx,
3546 )
3547 });
3548 Task::ready(Ok(thread))
3549 }
3550
3551 fn auth_methods(&self) -> &[acp::AuthMethod] {
3552 &[]
3553 }
3554
3555 fn authenticate(
3556 &self,
3557 _method_id: acp::AuthMethodId,
3558 _cx: &mut App,
3559 ) -> Task<gpui::Result<()>> {
3560 Task::ready(Ok(()))
3561 }
3562
3563 fn prompt(
3564 &self,
3565 _id: Option<acp_thread::UserMessageId>,
3566 _params: acp::PromptRequest,
3567 _cx: &mut App,
3568 ) -> Task<gpui::Result<acp::PromptResponse>> {
3569 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3570 }
3571
3572 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3573
3574 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3575 self
3576 }
3577 }
3578
3579 pub(crate) fn init_test(cx: &mut TestAppContext) {
3580 cx.update(|cx| {
3581 let settings_store = SettingsStore::test(cx);
3582 cx.set_global(settings_store);
3583 theme::init(theme::LoadThemes::JustBase, cx);
3584 editor::init(cx);
3585 release_channel::init(semver::Version::new(0, 0, 0), cx);
3586 prompt_store::init(cx)
3587 });
3588 }
3589
3590 fn active_thread(
3591 thread_view: &Entity<AcpServerView>,
3592 cx: &TestAppContext,
3593 ) -> Entity<AcpThreadView> {
3594 cx.read(|cx| thread_view.read(cx).as_connected().unwrap().current.clone())
3595 }
3596
3597 fn message_editor(
3598 thread_view: &Entity<AcpServerView>,
3599 cx: &TestAppContext,
3600 ) -> Entity<MessageEditor> {
3601 let thread = active_thread(thread_view, cx);
3602 cx.read(|cx| thread.read(cx).message_editor.clone())
3603 }
3604
3605 #[gpui::test]
3606 async fn test_rewind_views(cx: &mut TestAppContext) {
3607 init_test(cx);
3608
3609 let fs = FakeFs::new(cx.executor());
3610 fs.insert_tree(
3611 "/project",
3612 json!({
3613 "test1.txt": "old content 1",
3614 "test2.txt": "old content 2"
3615 }),
3616 )
3617 .await;
3618 let project = Project::test(fs, [Path::new("/project")], cx).await;
3619 let (workspace, cx) =
3620 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3621
3622 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3623 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
3624
3625 let connection = Rc::new(StubAgentConnection::new());
3626 let thread_view = cx.update(|window, cx| {
3627 cx.new(|cx| {
3628 AcpServerView::new(
3629 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
3630 None,
3631 None,
3632 workspace.downgrade(),
3633 project.clone(),
3634 Some(thread_store.clone()),
3635 None,
3636 history,
3637 window,
3638 cx,
3639 )
3640 })
3641 });
3642
3643 cx.run_until_parked();
3644
3645 let thread = thread_view
3646 .read_with(cx, |view, cx| {
3647 view.active_thread().map(|r| r.read(cx).thread.clone())
3648 })
3649 .unwrap();
3650
3651 // First user message
3652 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
3653 acp::ToolCall::new("tool1", "Edit file 1")
3654 .kind(acp::ToolKind::Edit)
3655 .status(acp::ToolCallStatus::Completed)
3656 .content(vec![acp::ToolCallContent::Diff(
3657 acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
3658 )]),
3659 )]);
3660
3661 thread
3662 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
3663 .await
3664 .unwrap();
3665 cx.run_until_parked();
3666
3667 thread.read_with(cx, |thread, _cx| {
3668 assert_eq!(thread.entries().len(), 2);
3669 });
3670
3671 thread_view.read_with(cx, |view, cx| {
3672 let entry_view_state = view
3673 .active_thread()
3674 .map(|active| active.read(cx).entry_view_state.clone())
3675 .unwrap();
3676 entry_view_state.read_with(cx, |entry_view_state, _| {
3677 assert!(
3678 entry_view_state
3679 .entry(0)
3680 .unwrap()
3681 .message_editor()
3682 .is_some()
3683 );
3684 assert!(entry_view_state.entry(1).unwrap().has_content());
3685 });
3686 });
3687
3688 // Second user message
3689 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
3690 acp::ToolCall::new("tool2", "Edit file 2")
3691 .kind(acp::ToolKind::Edit)
3692 .status(acp::ToolCallStatus::Completed)
3693 .content(vec![acp::ToolCallContent::Diff(
3694 acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
3695 )]),
3696 )]);
3697
3698 thread
3699 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
3700 .await
3701 .unwrap();
3702 cx.run_until_parked();
3703
3704 let second_user_message_id = thread.read_with(cx, |thread, _| {
3705 assert_eq!(thread.entries().len(), 4);
3706 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
3707 panic!();
3708 };
3709 user_message.id.clone().unwrap()
3710 });
3711
3712 thread_view.read_with(cx, |view, cx| {
3713 let entry_view_state = view
3714 .active_thread()
3715 .unwrap()
3716 .read(cx)
3717 .entry_view_state
3718 .clone();
3719 entry_view_state.read_with(cx, |entry_view_state, _| {
3720 assert!(
3721 entry_view_state
3722 .entry(0)
3723 .unwrap()
3724 .message_editor()
3725 .is_some()
3726 );
3727 assert!(entry_view_state.entry(1).unwrap().has_content());
3728 assert!(
3729 entry_view_state
3730 .entry(2)
3731 .unwrap()
3732 .message_editor()
3733 .is_some()
3734 );
3735 assert!(entry_view_state.entry(3).unwrap().has_content());
3736 });
3737 });
3738
3739 // Rewind to first message
3740 thread
3741 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
3742 .await
3743 .unwrap();
3744
3745 cx.run_until_parked();
3746
3747 thread.read_with(cx, |thread, _| {
3748 assert_eq!(thread.entries().len(), 2);
3749 });
3750
3751 thread_view.read_with(cx, |view, cx| {
3752 let active = view.active_thread().unwrap();
3753 active
3754 .read(cx)
3755 .entry_view_state
3756 .read_with(cx, |entry_view_state, _| {
3757 assert!(
3758 entry_view_state
3759 .entry(0)
3760 .unwrap()
3761 .message_editor()
3762 .is_some()
3763 );
3764 assert!(entry_view_state.entry(1).unwrap().has_content());
3765
3766 // Old views should be dropped
3767 assert!(entry_view_state.entry(2).is_none());
3768 assert!(entry_view_state.entry(3).is_none());
3769 });
3770 });
3771 }
3772
3773 #[gpui::test]
3774 async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
3775 init_test(cx);
3776
3777 let connection = StubAgentConnection::new();
3778
3779 // Each user prompt will result in a user message entry plus an agent message entry.
3780 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3781 acp::ContentChunk::new("Response 1".into()),
3782 )]);
3783
3784 let (thread_view, cx) =
3785 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
3786
3787 let thread = thread_view
3788 .read_with(cx, |view, cx| {
3789 view.active_thread().map(|r| r.read(cx).thread.clone())
3790 })
3791 .unwrap();
3792
3793 thread
3794 .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
3795 .await
3796 .unwrap();
3797 cx.run_until_parked();
3798
3799 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3800 acp::ContentChunk::new("Response 2".into()),
3801 )]);
3802
3803 thread
3804 .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
3805 .await
3806 .unwrap();
3807 cx.run_until_parked();
3808
3809 // Move somewhere else first so we're not trivially already on the last user prompt.
3810 active_thread(&thread_view, cx).update(cx, |view, cx| {
3811 view.scroll_to_top(cx);
3812 });
3813 cx.run_until_parked();
3814
3815 active_thread(&thread_view, cx).update(cx, |view, cx| {
3816 view.scroll_to_most_recent_user_prompt(cx);
3817 let scroll_top = view.list_state.logical_scroll_top();
3818 // Entries layout is: [User1, Assistant1, User2, Assistant2]
3819 assert_eq!(scroll_top.item_ix, 2);
3820 });
3821 }
3822
3823 #[gpui::test]
3824 async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
3825 cx: &mut TestAppContext,
3826 ) {
3827 init_test(cx);
3828
3829 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3830
3831 // With no entries, scrolling should be a no-op and must not panic.
3832 active_thread(&thread_view, cx).update(cx, |view, cx| {
3833 view.scroll_to_most_recent_user_prompt(cx);
3834 let scroll_top = view.list_state.logical_scroll_top();
3835 assert_eq!(scroll_top.item_ix, 0);
3836 });
3837 }
3838
3839 #[gpui::test]
3840 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
3841 init_test(cx);
3842
3843 let connection = StubAgentConnection::new();
3844
3845 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3846 acp::ContentChunk::new("Response".into()),
3847 )]);
3848
3849 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3850 add_to_workspace(thread_view.clone(), cx);
3851
3852 let message_editor = message_editor(&thread_view, cx);
3853 message_editor.update_in(cx, |editor, window, cx| {
3854 editor.set_text("Original message to edit", window, cx);
3855 });
3856 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3857
3858 cx.run_until_parked();
3859
3860 let user_message_editor = thread_view.read_with(cx, |view, cx| {
3861 assert_eq!(
3862 view.active_thread()
3863 .and_then(|active| active.read(cx).editing_message),
3864 None
3865 );
3866
3867 view.active_thread()
3868 .map(|active| &active.read(cx).entry_view_state)
3869 .as_ref()
3870 .unwrap()
3871 .read(cx)
3872 .entry(0)
3873 .unwrap()
3874 .message_editor()
3875 .unwrap()
3876 .clone()
3877 });
3878
3879 // Focus
3880 cx.focus(&user_message_editor);
3881 thread_view.read_with(cx, |view, cx| {
3882 assert_eq!(
3883 view.active_thread()
3884 .and_then(|active| active.read(cx).editing_message),
3885 Some(0)
3886 );
3887 });
3888
3889 // Edit
3890 user_message_editor.update_in(cx, |editor, window, cx| {
3891 editor.set_text("Edited message content", window, cx);
3892 });
3893
3894 // Cancel
3895 user_message_editor.update_in(cx, |_editor, window, cx| {
3896 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
3897 });
3898
3899 thread_view.read_with(cx, |view, cx| {
3900 assert_eq!(
3901 view.active_thread()
3902 .and_then(|active| active.read(cx).editing_message),
3903 None
3904 );
3905 });
3906
3907 user_message_editor.read_with(cx, |editor, cx| {
3908 assert_eq!(editor.text(cx), "Original message to edit");
3909 });
3910 }
3911
3912 #[gpui::test]
3913 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
3914 init_test(cx);
3915
3916 let connection = StubAgentConnection::new();
3917
3918 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3919 add_to_workspace(thread_view.clone(), cx);
3920
3921 let message_editor = message_editor(&thread_view, cx);
3922 message_editor.update_in(cx, |editor, window, cx| {
3923 editor.set_text("", window, cx);
3924 });
3925
3926 let thread = cx.read(|cx| {
3927 thread_view
3928 .read(cx)
3929 .active_thread()
3930 .unwrap()
3931 .read(cx)
3932 .thread
3933 .clone()
3934 });
3935 let entries_before = cx.read(|cx| thread.read(cx).entries().len());
3936
3937 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
3938 view.send(window, cx);
3939 });
3940 cx.run_until_parked();
3941
3942 let entries_after = cx.read(|cx| thread.read(cx).entries().len());
3943 assert_eq!(
3944 entries_before, entries_after,
3945 "No message should be sent when editor is empty"
3946 );
3947 }
3948
3949 #[gpui::test]
3950 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
3951 init_test(cx);
3952
3953 let connection = StubAgentConnection::new();
3954
3955 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3956 acp::ContentChunk::new("Response".into()),
3957 )]);
3958
3959 let (thread_view, cx) =
3960 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
3961 add_to_workspace(thread_view.clone(), cx);
3962
3963 let message_editor = message_editor(&thread_view, cx);
3964 message_editor.update_in(cx, |editor, window, cx| {
3965 editor.set_text("Original message to edit", window, cx);
3966 });
3967 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3968
3969 cx.run_until_parked();
3970
3971 let user_message_editor = thread_view.read_with(cx, |view, cx| {
3972 assert_eq!(
3973 view.active_thread()
3974 .and_then(|active| active.read(cx).editing_message),
3975 None
3976 );
3977 assert_eq!(
3978 view.active_thread()
3979 .unwrap()
3980 .read(cx)
3981 .thread
3982 .read(cx)
3983 .entries()
3984 .len(),
3985 2
3986 );
3987
3988 view.active_thread()
3989 .map(|active| &active.read(cx).entry_view_state)
3990 .as_ref()
3991 .unwrap()
3992 .read(cx)
3993 .entry(0)
3994 .unwrap()
3995 .message_editor()
3996 .unwrap()
3997 .clone()
3998 });
3999
4000 // Focus
4001 cx.focus(&user_message_editor);
4002
4003 // Edit
4004 user_message_editor.update_in(cx, |editor, window, cx| {
4005 editor.set_text("Edited message content", window, cx);
4006 });
4007
4008 // Send
4009 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4010 acp::ContentChunk::new("New Response".into()),
4011 )]);
4012
4013 user_message_editor.update_in(cx, |_editor, window, cx| {
4014 window.dispatch_action(Box::new(Chat), cx);
4015 });
4016
4017 cx.run_until_parked();
4018
4019 thread_view.read_with(cx, |view, cx| {
4020 assert_eq!(
4021 view.active_thread()
4022 .and_then(|active| active.read(cx).editing_message),
4023 None
4024 );
4025
4026 let entries = view
4027 .active_thread()
4028 .unwrap()
4029 .read(cx)
4030 .thread
4031 .read(cx)
4032 .entries();
4033 assert_eq!(entries.len(), 2);
4034 assert_eq!(
4035 entries[0].to_markdown(cx),
4036 "## User\n\nEdited message content\n\n"
4037 );
4038 assert_eq!(
4039 entries[1].to_markdown(cx),
4040 "## Assistant\n\nNew Response\n\n"
4041 );
4042
4043 let entry_view_state = view
4044 .active_thread()
4045 .map(|active| &active.read(cx).entry_view_state)
4046 .unwrap();
4047 let new_editor = entry_view_state.read_with(cx, |state, _cx| {
4048 assert!(!state.entry(1).unwrap().has_content());
4049 state.entry(0).unwrap().message_editor().unwrap().clone()
4050 });
4051
4052 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4053 })
4054 }
4055
4056 #[gpui::test]
4057 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4058 init_test(cx);
4059
4060 let connection = StubAgentConnection::new();
4061
4062 let (thread_view, cx) =
4063 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4064 add_to_workspace(thread_view.clone(), cx);
4065
4066 let message_editor = message_editor(&thread_view, cx);
4067 message_editor.update_in(cx, |editor, window, cx| {
4068 editor.set_text("Original message to edit", window, cx);
4069 });
4070 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4071
4072 cx.run_until_parked();
4073
4074 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
4075 let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
4076 assert_eq!(thread.entries().len(), 1);
4077
4078 let editor = view
4079 .active_thread()
4080 .map(|active| &active.read(cx).entry_view_state)
4081 .as_ref()
4082 .unwrap()
4083 .read(cx)
4084 .entry(0)
4085 .unwrap()
4086 .message_editor()
4087 .unwrap()
4088 .clone();
4089
4090 (editor, thread.session_id().clone())
4091 });
4092
4093 // Focus
4094 cx.focus(&user_message_editor);
4095
4096 thread_view.read_with(cx, |view, cx| {
4097 assert_eq!(
4098 view.active_thread()
4099 .and_then(|active| active.read(cx).editing_message),
4100 Some(0)
4101 );
4102 });
4103
4104 // Edit
4105 user_message_editor.update_in(cx, |editor, window, cx| {
4106 editor.set_text("Edited message content", window, cx);
4107 });
4108
4109 thread_view.read_with(cx, |view, cx| {
4110 assert_eq!(
4111 view.active_thread()
4112 .and_then(|active| active.read(cx).editing_message),
4113 Some(0)
4114 );
4115 });
4116
4117 // Finish streaming response
4118 cx.update(|_, cx| {
4119 connection.send_update(
4120 session_id.clone(),
4121 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
4122 cx,
4123 );
4124 connection.end_turn(session_id, acp::StopReason::EndTurn);
4125 });
4126
4127 thread_view.read_with(cx, |view, cx| {
4128 assert_eq!(
4129 view.active_thread()
4130 .and_then(|active| active.read(cx).editing_message),
4131 Some(0)
4132 );
4133 });
4134
4135 cx.run_until_parked();
4136
4137 // Should still be editing
4138 cx.update(|window, cx| {
4139 assert!(user_message_editor.focus_handle(cx).is_focused(window));
4140 assert_eq!(
4141 thread_view
4142 .read(cx)
4143 .active_thread()
4144 .and_then(|active| active.read(cx).editing_message),
4145 Some(0)
4146 );
4147 assert_eq!(
4148 user_message_editor.read(cx).text(cx),
4149 "Edited message content"
4150 );
4151 });
4152 }
4153
4154 struct GeneratingThreadSetup {
4155 thread_view: Entity<AcpServerView>,
4156 thread: Entity<AcpThread>,
4157 message_editor: Entity<MessageEditor>,
4158 }
4159
4160 async fn setup_generating_thread(
4161 cx: &mut TestAppContext,
4162 ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
4163 let connection = StubAgentConnection::new();
4164
4165 let (thread_view, cx) =
4166 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4167 add_to_workspace(thread_view.clone(), cx);
4168
4169 let message_editor = message_editor(&thread_view, cx);
4170 message_editor.update_in(cx, |editor, window, cx| {
4171 editor.set_text("Hello", window, cx);
4172 });
4173 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4174
4175 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4176 let thread = view
4177 .active_thread()
4178 .as_ref()
4179 .unwrap()
4180 .read(cx)
4181 .thread
4182 .clone();
4183 (thread.clone(), thread.read(cx).session_id().clone())
4184 });
4185
4186 cx.run_until_parked();
4187
4188 cx.update(|_, cx| {
4189 connection.send_update(
4190 session_id.clone(),
4191 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4192 "Response chunk".into(),
4193 )),
4194 cx,
4195 );
4196 });
4197
4198 cx.run_until_parked();
4199
4200 thread.read_with(cx, |thread, _cx| {
4201 assert_eq!(thread.status(), ThreadStatus::Generating);
4202 });
4203
4204 (
4205 GeneratingThreadSetup {
4206 thread_view,
4207 thread,
4208 message_editor,
4209 },
4210 cx,
4211 )
4212 }
4213
4214 #[gpui::test]
4215 async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
4216 init_test(cx);
4217
4218 let (setup, cx) = setup_generating_thread(cx).await;
4219
4220 let focus_handle = setup
4221 .thread_view
4222 .read_with(cx, |view, cx| view.focus_handle(cx));
4223 cx.update(|window, cx| {
4224 window.focus(&focus_handle, cx);
4225 });
4226
4227 setup.thread_view.update_in(cx, |_, window, cx| {
4228 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
4229 });
4230
4231 cx.run_until_parked();
4232
4233 setup.thread.read_with(cx, |thread, _cx| {
4234 assert_eq!(thread.status(), ThreadStatus::Idle);
4235 });
4236 }
4237
4238 #[gpui::test]
4239 async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
4240 init_test(cx);
4241
4242 let (setup, cx) = setup_generating_thread(cx).await;
4243
4244 let editor_focus_handle = setup
4245 .message_editor
4246 .read_with(cx, |editor, cx| editor.focus_handle(cx));
4247 cx.update(|window, cx| {
4248 window.focus(&editor_focus_handle, cx);
4249 });
4250
4251 setup.message_editor.update_in(cx, |_, window, cx| {
4252 window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
4253 });
4254
4255 cx.run_until_parked();
4256
4257 setup.thread.read_with(cx, |thread, _cx| {
4258 assert_eq!(thread.status(), ThreadStatus::Idle);
4259 });
4260 }
4261
4262 #[gpui::test]
4263 async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
4264 init_test(cx);
4265
4266 let (thread_view, cx) =
4267 setup_thread_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
4268 add_to_workspace(thread_view.clone(), cx);
4269
4270 let thread = thread_view.read_with(cx, |view, cx| {
4271 view.active_thread().unwrap().read(cx).thread.clone()
4272 });
4273
4274 thread.read_with(cx, |thread, _cx| {
4275 assert_eq!(thread.status(), ThreadStatus::Idle);
4276 });
4277
4278 let focus_handle = thread_view.read_with(cx, |view, _cx| view.focus_handle.clone());
4279 cx.update(|window, cx| {
4280 window.focus(&focus_handle, cx);
4281 });
4282
4283 thread_view.update_in(cx, |_, window, cx| {
4284 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
4285 });
4286
4287 cx.run_until_parked();
4288
4289 thread.read_with(cx, |thread, _cx| {
4290 assert_eq!(thread.status(), ThreadStatus::Idle);
4291 });
4292 }
4293
4294 #[gpui::test]
4295 async fn test_interrupt(cx: &mut TestAppContext) {
4296 init_test(cx);
4297
4298 let connection = StubAgentConnection::new();
4299
4300 let (thread_view, cx) =
4301 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4302 add_to_workspace(thread_view.clone(), cx);
4303
4304 let message_editor = message_editor(&thread_view, cx);
4305 message_editor.update_in(cx, |editor, window, cx| {
4306 editor.set_text("Message 1", window, cx);
4307 });
4308 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4309
4310 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4311 let thread = view.active_thread().unwrap().read(cx).thread.clone();
4312
4313 (thread.clone(), thread.read(cx).session_id().clone())
4314 });
4315
4316 cx.run_until_parked();
4317
4318 cx.update(|_, cx| {
4319 connection.send_update(
4320 session_id.clone(),
4321 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4322 "Message 1 resp".into(),
4323 )),
4324 cx,
4325 );
4326 });
4327
4328 cx.run_until_parked();
4329
4330 thread.read_with(cx, |thread, cx| {
4331 assert_eq!(
4332 thread.to_markdown(cx),
4333 indoc::indoc! {"
4334 ## User
4335
4336 Message 1
4337
4338 ## Assistant
4339
4340 Message 1 resp
4341
4342 "}
4343 )
4344 });
4345
4346 message_editor.update_in(cx, |editor, window, cx| {
4347 editor.set_text("Message 2", window, cx);
4348 });
4349 active_thread(&thread_view, cx)
4350 .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
4351
4352 cx.update(|_, cx| {
4353 // Simulate a response sent after beginning to cancel
4354 connection.send_update(
4355 session_id.clone(),
4356 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
4357 cx,
4358 );
4359 });
4360
4361 cx.run_until_parked();
4362
4363 // Last Message 1 response should appear before Message 2
4364 thread.read_with(cx, |thread, cx| {
4365 assert_eq!(
4366 thread.to_markdown(cx),
4367 indoc::indoc! {"
4368 ## User
4369
4370 Message 1
4371
4372 ## Assistant
4373
4374 Message 1 response
4375
4376 ## User
4377
4378 Message 2
4379
4380 "}
4381 )
4382 });
4383
4384 cx.update(|_, cx| {
4385 connection.send_update(
4386 session_id.clone(),
4387 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4388 "Message 2 response".into(),
4389 )),
4390 cx,
4391 );
4392 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4393 });
4394
4395 cx.run_until_parked();
4396
4397 thread.read_with(cx, |thread, cx| {
4398 assert_eq!(
4399 thread.to_markdown(cx),
4400 indoc::indoc! {"
4401 ## User
4402
4403 Message 1
4404
4405 ## Assistant
4406
4407 Message 1 response
4408
4409 ## User
4410
4411 Message 2
4412
4413 ## Assistant
4414
4415 Message 2 response
4416
4417 "}
4418 )
4419 });
4420 }
4421
4422 #[gpui::test]
4423 async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
4424 init_test(cx);
4425
4426 let connection = StubAgentConnection::new();
4427 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4428 acp::ContentChunk::new("Response".into()),
4429 )]);
4430
4431 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4432 add_to_workspace(thread_view.clone(), cx);
4433
4434 let message_editor = message_editor(&thread_view, cx);
4435 message_editor.update_in(cx, |editor, window, cx| {
4436 editor.set_text("Original message to edit", window, cx)
4437 });
4438 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4439 cx.run_until_parked();
4440
4441 let user_message_editor = thread_view.read_with(cx, |thread_view, cx| {
4442 thread_view
4443 .active_thread()
4444 .map(|active| &active.read(cx).entry_view_state)
4445 .as_ref()
4446 .unwrap()
4447 .read(cx)
4448 .entry(0)
4449 .expect("Should have at least one entry")
4450 .message_editor()
4451 .expect("Should have message editor")
4452 .clone()
4453 });
4454
4455 cx.focus(&user_message_editor);
4456 thread_view.read_with(cx, |view, cx| {
4457 assert_eq!(
4458 view.active_thread()
4459 .and_then(|active| active.read(cx).editing_message),
4460 Some(0)
4461 );
4462 });
4463
4464 // Ensure to edit the focused message before proceeding otherwise, since
4465 // its content is not different from what was sent, focus will be lost.
4466 user_message_editor.update_in(cx, |editor, window, cx| {
4467 editor.set_text("Original message to edit with ", window, cx)
4468 });
4469
4470 // Create a simple buffer with some text so we can create a selection
4471 // that will then be added to the message being edited.
4472 let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
4473 (thread_view.workspace.clone(), thread_view.project.clone())
4474 });
4475 let buffer = project.update(cx, |project, cx| {
4476 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
4477 });
4478
4479 workspace
4480 .update_in(cx, |workspace, window, cx| {
4481 let editor = cx.new(|cx| {
4482 let mut editor =
4483 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
4484
4485 editor.change_selections(Default::default(), window, cx, |selections| {
4486 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
4487 });
4488
4489 editor
4490 });
4491 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
4492 })
4493 .unwrap();
4494
4495 thread_view.update_in(cx, |view, window, cx| {
4496 assert_eq!(
4497 view.active_thread()
4498 .and_then(|active| active.read(cx).editing_message),
4499 Some(0)
4500 );
4501 view.insert_selections(window, cx);
4502 });
4503
4504 user_message_editor.read_with(cx, |editor, cx| {
4505 let text = editor.editor().read(cx).text(cx);
4506 let expected_text = String::from("Original message to edit with selection ");
4507
4508 assert_eq!(text, expected_text);
4509 });
4510 }
4511
4512 #[gpui::test]
4513 async fn test_insert_selections(cx: &mut TestAppContext) {
4514 init_test(cx);
4515
4516 let connection = StubAgentConnection::new();
4517 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4518 acp::ContentChunk::new("Response".into()),
4519 )]);
4520
4521 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4522 add_to_workspace(thread_view.clone(), cx);
4523
4524 let message_editor = message_editor(&thread_view, cx);
4525 message_editor.update_in(cx, |editor, window, cx| {
4526 editor.set_text("Can you review this snippet ", window, cx)
4527 });
4528
4529 // Create a simple buffer with some text so we can create a selection
4530 // that will then be added to the message being edited.
4531 let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
4532 (thread_view.workspace.clone(), thread_view.project.clone())
4533 });
4534 let buffer = project.update(cx, |project, cx| {
4535 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
4536 });
4537
4538 workspace
4539 .update_in(cx, |workspace, window, cx| {
4540 let editor = cx.new(|cx| {
4541 let mut editor =
4542 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
4543
4544 editor.change_selections(Default::default(), window, cx, |selections| {
4545 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
4546 });
4547
4548 editor
4549 });
4550 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
4551 })
4552 .unwrap();
4553
4554 thread_view.update_in(cx, |view, window, cx| {
4555 assert_eq!(
4556 view.active_thread()
4557 .and_then(|active| active.read(cx).editing_message),
4558 None
4559 );
4560 view.insert_selections(window, cx);
4561 });
4562
4563 message_editor.read_with(cx, |editor, cx| {
4564 let text = editor.text(cx);
4565 let expected_txt = String::from("Can you review this snippet selection ");
4566
4567 assert_eq!(text, expected_txt);
4568 })
4569 }
4570
4571 #[gpui::test]
4572 async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
4573 init_test(cx);
4574
4575 let tool_call_id = acp::ToolCallId::new("terminal-1");
4576 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
4577 .kind(acp::ToolKind::Edit);
4578
4579 let permission_options =
4580 ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
4581 .build_permission_options();
4582
4583 let connection =
4584 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4585 tool_call_id.clone(),
4586 permission_options,
4587 )]));
4588
4589 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4590
4591 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4592
4593 // Disable notifications to avoid popup windows
4594 cx.update(|_window, cx| {
4595 AgentSettings::override_global(
4596 AgentSettings {
4597 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4598 ..AgentSettings::get_global(cx).clone()
4599 },
4600 cx,
4601 );
4602 });
4603
4604 let message_editor = message_editor(&thread_view, cx);
4605 message_editor.update_in(cx, |editor, window, cx| {
4606 editor.set_text("Run cargo build", window, cx);
4607 });
4608
4609 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4610
4611 cx.run_until_parked();
4612
4613 // Verify the tool call is in WaitingForConfirmation state with the expected options
4614 thread_view.read_with(cx, |thread_view, cx| {
4615 let thread = thread_view
4616 .active_thread()
4617 .expect("Thread should exist")
4618 .read(cx)
4619 .thread
4620 .clone();
4621 let thread = thread.read(cx);
4622
4623 let tool_call = thread.entries().iter().find_map(|entry| {
4624 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
4625 Some(call)
4626 } else {
4627 None
4628 }
4629 });
4630
4631 assert!(tool_call.is_some(), "Expected a tool call entry");
4632 let tool_call = tool_call.unwrap();
4633
4634 // Verify it's waiting for confirmation
4635 assert!(
4636 matches!(
4637 tool_call.status,
4638 acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
4639 ),
4640 "Expected WaitingForConfirmation status, got {:?}",
4641 tool_call.status
4642 );
4643
4644 // Verify the options count (granularity options only, no separate Deny option)
4645 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
4646 &tool_call.status
4647 {
4648 let PermissionOptions::Dropdown(choices) = options else {
4649 panic!("Expected dropdown permission options");
4650 };
4651
4652 assert_eq!(
4653 choices.len(),
4654 3,
4655 "Expected 3 permission options (granularity only)"
4656 );
4657
4658 // Verify specific button labels (now using neutral names)
4659 let labels: Vec<&str> = choices
4660 .iter()
4661 .map(|choice| choice.allow.name.as_ref())
4662 .collect();
4663 assert!(
4664 labels.contains(&"Always for terminal"),
4665 "Missing 'Always for terminal' option"
4666 );
4667 assert!(
4668 labels.contains(&"Always for `cargo` commands"),
4669 "Missing pattern option"
4670 );
4671 assert!(
4672 labels.contains(&"Only this time"),
4673 "Missing 'Only this time' option"
4674 );
4675 }
4676 });
4677 }
4678
4679 #[gpui::test]
4680 async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
4681 init_test(cx);
4682
4683 let tool_call_id = acp::ToolCallId::new("edit-file-1");
4684 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
4685 .kind(acp::ToolKind::Edit);
4686
4687 let permission_options = ToolPermissionContext::new(EditFileTool::NAME, "src/main.rs")
4688 .build_permission_options();
4689
4690 let connection =
4691 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4692 tool_call_id.clone(),
4693 permission_options,
4694 )]));
4695
4696 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4697
4698 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4699
4700 // Disable notifications
4701 cx.update(|_window, cx| {
4702 AgentSettings::override_global(
4703 AgentSettings {
4704 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4705 ..AgentSettings::get_global(cx).clone()
4706 },
4707 cx,
4708 );
4709 });
4710
4711 let message_editor = message_editor(&thread_view, cx);
4712 message_editor.update_in(cx, |editor, window, cx| {
4713 editor.set_text("Edit the main file", window, cx);
4714 });
4715
4716 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4717
4718 cx.run_until_parked();
4719
4720 // Verify the options
4721 thread_view.read_with(cx, |thread_view, cx| {
4722 let thread = thread_view
4723 .active_thread()
4724 .expect("Thread should exist")
4725 .read(cx)
4726 .thread
4727 .clone();
4728 let thread = thread.read(cx);
4729
4730 let tool_call = thread.entries().iter().find_map(|entry| {
4731 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
4732 Some(call)
4733 } else {
4734 None
4735 }
4736 });
4737
4738 assert!(tool_call.is_some(), "Expected a tool call entry");
4739 let tool_call = tool_call.unwrap();
4740
4741 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
4742 &tool_call.status
4743 {
4744 let PermissionOptions::Dropdown(choices) = options else {
4745 panic!("Expected dropdown permission options");
4746 };
4747
4748 let labels: Vec<&str> = choices
4749 .iter()
4750 .map(|choice| choice.allow.name.as_ref())
4751 .collect();
4752 assert!(
4753 labels.contains(&"Always for edit file"),
4754 "Missing 'Always for edit file' option"
4755 );
4756 assert!(
4757 labels.contains(&"Always for `src/`"),
4758 "Missing path pattern option"
4759 );
4760 } else {
4761 panic!("Expected WaitingForConfirmation status");
4762 }
4763 });
4764 }
4765
4766 #[gpui::test]
4767 async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
4768 init_test(cx);
4769
4770 let tool_call_id = acp::ToolCallId::new("fetch-1");
4771 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
4772 .kind(acp::ToolKind::Fetch);
4773
4774 let permission_options =
4775 ToolPermissionContext::new(FetchTool::NAME, "https://docs.rs/gpui")
4776 .build_permission_options();
4777
4778 let connection =
4779 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4780 tool_call_id.clone(),
4781 permission_options,
4782 )]));
4783
4784 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4785
4786 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4787
4788 // Disable notifications
4789 cx.update(|_window, cx| {
4790 AgentSettings::override_global(
4791 AgentSettings {
4792 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4793 ..AgentSettings::get_global(cx).clone()
4794 },
4795 cx,
4796 );
4797 });
4798
4799 let message_editor = message_editor(&thread_view, cx);
4800 message_editor.update_in(cx, |editor, window, cx| {
4801 editor.set_text("Fetch the docs", window, cx);
4802 });
4803
4804 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4805
4806 cx.run_until_parked();
4807
4808 // Verify the options
4809 thread_view.read_with(cx, |thread_view, cx| {
4810 let thread = thread_view
4811 .active_thread()
4812 .expect("Thread should exist")
4813 .read(cx)
4814 .thread
4815 .clone();
4816 let thread = thread.read(cx);
4817
4818 let tool_call = thread.entries().iter().find_map(|entry| {
4819 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
4820 Some(call)
4821 } else {
4822 None
4823 }
4824 });
4825
4826 assert!(tool_call.is_some(), "Expected a tool call entry");
4827 let tool_call = tool_call.unwrap();
4828
4829 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
4830 &tool_call.status
4831 {
4832 let PermissionOptions::Dropdown(choices) = options else {
4833 panic!("Expected dropdown permission options");
4834 };
4835
4836 let labels: Vec<&str> = choices
4837 .iter()
4838 .map(|choice| choice.allow.name.as_ref())
4839 .collect();
4840 assert!(
4841 labels.contains(&"Always for fetch"),
4842 "Missing 'Always for fetch' option"
4843 );
4844 assert!(
4845 labels.contains(&"Always for `docs.rs`"),
4846 "Missing domain pattern option"
4847 );
4848 } else {
4849 panic!("Expected WaitingForConfirmation status");
4850 }
4851 });
4852 }
4853
4854 #[gpui::test]
4855 async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
4856 init_test(cx);
4857
4858 let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
4859 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
4860 .kind(acp::ToolKind::Edit);
4861
4862 // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
4863 let permission_options =
4864 ToolPermissionContext::new(TerminalTool::NAME, "./deploy.sh --production")
4865 .build_permission_options();
4866
4867 let connection =
4868 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4869 tool_call_id.clone(),
4870 permission_options,
4871 )]));
4872
4873 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4874
4875 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4876
4877 // Disable notifications
4878 cx.update(|_window, cx| {
4879 AgentSettings::override_global(
4880 AgentSettings {
4881 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4882 ..AgentSettings::get_global(cx).clone()
4883 },
4884 cx,
4885 );
4886 });
4887
4888 let message_editor = message_editor(&thread_view, cx);
4889 message_editor.update_in(cx, |editor, window, cx| {
4890 editor.set_text("Run the deploy script", window, cx);
4891 });
4892
4893 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4894
4895 cx.run_until_parked();
4896
4897 // Verify only 2 options (no pattern button when command doesn't match pattern)
4898 thread_view.read_with(cx, |thread_view, cx| {
4899 let thread = thread_view
4900 .active_thread()
4901 .expect("Thread should exist")
4902 .read(cx)
4903 .thread
4904 .clone();
4905 let thread = thread.read(cx);
4906
4907 let tool_call = thread.entries().iter().find_map(|entry| {
4908 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
4909 Some(call)
4910 } else {
4911 None
4912 }
4913 });
4914
4915 assert!(tool_call.is_some(), "Expected a tool call entry");
4916 let tool_call = tool_call.unwrap();
4917
4918 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
4919 &tool_call.status
4920 {
4921 let PermissionOptions::Dropdown(choices) = options else {
4922 panic!("Expected dropdown permission options");
4923 };
4924
4925 assert_eq!(
4926 choices.len(),
4927 2,
4928 "Expected 2 permission options (no pattern option)"
4929 );
4930
4931 let labels: Vec<&str> = choices
4932 .iter()
4933 .map(|choice| choice.allow.name.as_ref())
4934 .collect();
4935 assert!(
4936 labels.contains(&"Always for terminal"),
4937 "Missing 'Always for terminal' option"
4938 );
4939 assert!(
4940 labels.contains(&"Only this time"),
4941 "Missing 'Only this time' option"
4942 );
4943 // Should NOT contain a pattern option
4944 assert!(
4945 !labels.iter().any(|l| l.contains("commands")),
4946 "Should not have pattern option"
4947 );
4948 } else {
4949 panic!("Expected WaitingForConfirmation status");
4950 }
4951 });
4952 }
4953
4954 #[gpui::test]
4955 async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
4956 init_test(cx);
4957
4958 let tool_call_id = acp::ToolCallId::new("action-test-1");
4959 let tool_call =
4960 acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
4961
4962 let permission_options =
4963 ToolPermissionContext::new(TerminalTool::NAME, "cargo test").build_permission_options();
4964
4965 let connection =
4966 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4967 tool_call_id.clone(),
4968 permission_options,
4969 )]));
4970
4971 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4972
4973 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4974 add_to_workspace(thread_view.clone(), cx);
4975
4976 cx.update(|_window, cx| {
4977 AgentSettings::override_global(
4978 AgentSettings {
4979 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4980 ..AgentSettings::get_global(cx).clone()
4981 },
4982 cx,
4983 );
4984 });
4985
4986 let message_editor = message_editor(&thread_view, cx);
4987 message_editor.update_in(cx, |editor, window, cx| {
4988 editor.set_text("Run tests", window, cx);
4989 });
4990
4991 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4992
4993 cx.run_until_parked();
4994
4995 // Verify tool call is waiting for confirmation
4996 thread_view.read_with(cx, |thread_view, cx| {
4997 let thread = thread_view
4998 .active_thread()
4999 .expect("Thread should exist")
5000 .read(cx)
5001 .thread
5002 .clone();
5003 let thread = thread.read(cx);
5004 let tool_call = thread.first_tool_awaiting_confirmation();
5005 assert!(
5006 tool_call.is_some(),
5007 "Expected a tool call waiting for confirmation"
5008 );
5009 });
5010
5011 // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
5012 thread_view.update_in(cx, |_, window, cx| {
5013 window.dispatch_action(
5014 crate::AuthorizeToolCall {
5015 tool_call_id: "action-test-1".to_string(),
5016 option_id: "allow".to_string(),
5017 option_kind: "AllowOnce".to_string(),
5018 }
5019 .boxed_clone(),
5020 cx,
5021 );
5022 });
5023
5024 cx.run_until_parked();
5025
5026 // Verify tool call is no longer waiting for confirmation (was authorized)
5027 thread_view.read_with(cx, |thread_view, cx| {
5028 let thread = thread_view.active_thread().expect("Thread should exist").read(cx).thread.clone();
5029 let thread = thread.read(cx);
5030 let tool_call = thread.first_tool_awaiting_confirmation();
5031 assert!(
5032 tool_call.is_none(),
5033 "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
5034 );
5035 });
5036 }
5037
5038 #[gpui::test]
5039 async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
5040 init_test(cx);
5041
5042 let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
5043 let tool_call =
5044 acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5045
5046 let permission_options = ToolPermissionContext::new(TerminalTool::NAME, "npm install")
5047 .build_permission_options();
5048
5049 let connection =
5050 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5051 tool_call_id.clone(),
5052 permission_options.clone(),
5053 )]));
5054
5055 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5056
5057 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5058 add_to_workspace(thread_view.clone(), cx);
5059
5060 cx.update(|_window, cx| {
5061 AgentSettings::override_global(
5062 AgentSettings {
5063 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5064 ..AgentSettings::get_global(cx).clone()
5065 },
5066 cx,
5067 );
5068 });
5069
5070 let message_editor = message_editor(&thread_view, cx);
5071 message_editor.update_in(cx, |editor, window, cx| {
5072 editor.set_text("Install dependencies", window, cx);
5073 });
5074
5075 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5076
5077 cx.run_until_parked();
5078
5079 // Find the pattern option ID
5080 let pattern_option = match &permission_options {
5081 PermissionOptions::Dropdown(choices) => choices
5082 .iter()
5083 .find(|choice| {
5084 choice
5085 .allow
5086 .option_id
5087 .0
5088 .starts_with("always_allow_pattern:")
5089 })
5090 .map(|choice| &choice.allow)
5091 .expect("Should have a pattern option for npm command"),
5092 _ => panic!("Expected dropdown permission options"),
5093 };
5094
5095 // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
5096 thread_view.update_in(cx, |_, window, cx| {
5097 window.dispatch_action(
5098 crate::AuthorizeToolCall {
5099 tool_call_id: "pattern-action-test-1".to_string(),
5100 option_id: pattern_option.option_id.0.to_string(),
5101 option_kind: "AllowAlways".to_string(),
5102 }
5103 .boxed_clone(),
5104 cx,
5105 );
5106 });
5107
5108 cx.run_until_parked();
5109
5110 // Verify tool call was authorized
5111 thread_view.read_with(cx, |thread_view, cx| {
5112 let thread = thread_view
5113 .active_thread()
5114 .expect("Thread should exist")
5115 .read(cx)
5116 .thread
5117 .clone();
5118 let thread = thread.read(cx);
5119 let tool_call = thread.first_tool_awaiting_confirmation();
5120 assert!(
5121 tool_call.is_none(),
5122 "Tool call should be authorized after selecting pattern option"
5123 );
5124 });
5125 }
5126
5127 #[gpui::test]
5128 async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
5129 init_test(cx);
5130
5131 let tool_call_id = acp::ToolCallId::new("granularity-test-1");
5132 let tool_call =
5133 acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
5134
5135 let permission_options = ToolPermissionContext::new(TerminalTool::NAME, "cargo build")
5136 .build_permission_options();
5137
5138 let connection =
5139 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5140 tool_call_id.clone(),
5141 permission_options.clone(),
5142 )]));
5143
5144 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5145
5146 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5147 add_to_workspace(thread_view.clone(), cx);
5148
5149 cx.update(|_window, cx| {
5150 AgentSettings::override_global(
5151 AgentSettings {
5152 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5153 ..AgentSettings::get_global(cx).clone()
5154 },
5155 cx,
5156 );
5157 });
5158
5159 let message_editor = message_editor(&thread_view, cx);
5160 message_editor.update_in(cx, |editor, window, cx| {
5161 editor.set_text("Build the project", window, cx);
5162 });
5163
5164 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5165
5166 cx.run_until_parked();
5167
5168 // Verify default granularity is the last option (index 2 = "Only this time")
5169 thread_view.read_with(cx, |thread_view, cx| {
5170 let state = thread_view.active_thread().unwrap();
5171 let selected = state
5172 .read(cx)
5173 .selected_permission_granularity
5174 .get(&tool_call_id);
5175 assert!(
5176 selected.is_none(),
5177 "Should have no selection initially (defaults to last)"
5178 );
5179 });
5180
5181 // Select the first option (index 0 = "Always for terminal")
5182 thread_view.update_in(cx, |_, window, cx| {
5183 window.dispatch_action(
5184 crate::SelectPermissionGranularity {
5185 tool_call_id: "granularity-test-1".to_string(),
5186 index: 0,
5187 }
5188 .boxed_clone(),
5189 cx,
5190 );
5191 });
5192
5193 cx.run_until_parked();
5194
5195 // Verify the selection was updated
5196 thread_view.read_with(cx, |thread_view, cx| {
5197 let state = thread_view.active_thread().unwrap();
5198 let selected = state
5199 .read(cx)
5200 .selected_permission_granularity
5201 .get(&tool_call_id);
5202 assert_eq!(selected, Some(&0), "Should have selected index 0");
5203 });
5204 }
5205
5206 #[gpui::test]
5207 async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
5208 init_test(cx);
5209
5210 let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
5211 let tool_call =
5212 acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5213
5214 let permission_options = ToolPermissionContext::new(TerminalTool::NAME, "npm install")
5215 .build_permission_options();
5216
5217 // Verify we have the expected options
5218 let PermissionOptions::Dropdown(choices) = &permission_options else {
5219 panic!("Expected dropdown permission options");
5220 };
5221
5222 assert_eq!(choices.len(), 3);
5223 assert!(
5224 choices[0]
5225 .allow
5226 .option_id
5227 .0
5228 .contains("always_allow:terminal")
5229 );
5230 assert!(
5231 choices[1]
5232 .allow
5233 .option_id
5234 .0
5235 .contains("always_allow_pattern:terminal")
5236 );
5237 assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
5238
5239 let connection =
5240 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5241 tool_call_id.clone(),
5242 permission_options.clone(),
5243 )]));
5244
5245 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5246
5247 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5248 add_to_workspace(thread_view.clone(), cx);
5249
5250 cx.update(|_window, cx| {
5251 AgentSettings::override_global(
5252 AgentSettings {
5253 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5254 ..AgentSettings::get_global(cx).clone()
5255 },
5256 cx,
5257 );
5258 });
5259
5260 let message_editor = message_editor(&thread_view, cx);
5261 message_editor.update_in(cx, |editor, window, cx| {
5262 editor.set_text("Install dependencies", window, cx);
5263 });
5264
5265 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5266
5267 cx.run_until_parked();
5268
5269 // Select the pattern option (index 1 = "Always for `npm` commands")
5270 thread_view.update_in(cx, |_, window, cx| {
5271 window.dispatch_action(
5272 crate::SelectPermissionGranularity {
5273 tool_call_id: "allow-granularity-test-1".to_string(),
5274 index: 1,
5275 }
5276 .boxed_clone(),
5277 cx,
5278 );
5279 });
5280
5281 cx.run_until_parked();
5282
5283 // Simulate clicking the Allow button by dispatching AllowOnce action
5284 // which should use the selected granularity
5285 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
5286 view.allow_once(&AllowOnce, window, cx)
5287 });
5288
5289 cx.run_until_parked();
5290
5291 // Verify tool call was authorized
5292 thread_view.read_with(cx, |thread_view, cx| {
5293 let thread = thread_view
5294 .active_thread()
5295 .expect("Thread should exist")
5296 .read(cx)
5297 .thread
5298 .clone();
5299 let thread = thread.read(cx);
5300 let tool_call = thread.first_tool_awaiting_confirmation();
5301 assert!(
5302 tool_call.is_none(),
5303 "Tool call should be authorized after Allow with pattern granularity"
5304 );
5305 });
5306 }
5307
5308 #[gpui::test]
5309 async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
5310 init_test(cx);
5311
5312 let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
5313 let tool_call =
5314 acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
5315
5316 let permission_options =
5317 ToolPermissionContext::new(TerminalTool::NAME, "git push").build_permission_options();
5318
5319 let connection =
5320 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5321 tool_call_id.clone(),
5322 permission_options.clone(),
5323 )]));
5324
5325 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5326
5327 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5328 add_to_workspace(thread_view.clone(), cx);
5329
5330 cx.update(|_window, cx| {
5331 AgentSettings::override_global(
5332 AgentSettings {
5333 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5334 ..AgentSettings::get_global(cx).clone()
5335 },
5336 cx,
5337 );
5338 });
5339
5340 let message_editor = message_editor(&thread_view, cx);
5341 message_editor.update_in(cx, |editor, window, cx| {
5342 editor.set_text("Push changes", window, cx);
5343 });
5344
5345 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5346
5347 cx.run_until_parked();
5348
5349 // Use default granularity (last option = "Only this time")
5350 // Simulate clicking the Deny button
5351 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
5352 view.reject_once(&RejectOnce, window, cx)
5353 });
5354
5355 cx.run_until_parked();
5356
5357 // Verify tool call was rejected (no longer waiting for confirmation)
5358 thread_view.read_with(cx, |thread_view, cx| {
5359 let thread = thread_view
5360 .active_thread()
5361 .expect("Thread should exist")
5362 .read(cx)
5363 .thread
5364 .clone();
5365 let thread = thread.read(cx);
5366 let tool_call = thread.first_tool_awaiting_confirmation();
5367 assert!(
5368 tool_call.is_none(),
5369 "Tool call should be rejected after Deny"
5370 );
5371 });
5372 }
5373
5374 #[gpui::test]
5375 async fn test_option_id_transformation_for_allow() {
5376 let permission_options =
5377 ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
5378 .build_permission_options();
5379
5380 let PermissionOptions::Dropdown(choices) = permission_options else {
5381 panic!("Expected dropdown permission options");
5382 };
5383
5384 let allow_ids: Vec<String> = choices
5385 .iter()
5386 .map(|choice| choice.allow.option_id.0.to_string())
5387 .collect();
5388
5389 assert!(allow_ids.contains(&"always_allow:terminal".to_string()));
5390 assert!(allow_ids.contains(&"allow".to_string()));
5391 assert!(
5392 allow_ids
5393 .iter()
5394 .any(|id| id.starts_with("always_allow_pattern:terminal\n")),
5395 "Missing allow pattern option"
5396 );
5397 }
5398
5399 #[gpui::test]
5400 async fn test_option_id_transformation_for_deny() {
5401 let permission_options =
5402 ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
5403 .build_permission_options();
5404
5405 let PermissionOptions::Dropdown(choices) = permission_options else {
5406 panic!("Expected dropdown permission options");
5407 };
5408
5409 let deny_ids: Vec<String> = choices
5410 .iter()
5411 .map(|choice| choice.deny.option_id.0.to_string())
5412 .collect();
5413
5414 assert!(deny_ids.contains(&"always_deny:terminal".to_string()));
5415 assert!(deny_ids.contains(&"deny".to_string()));
5416 assert!(
5417 deny_ids
5418 .iter()
5419 .any(|id| id.starts_with("always_deny_pattern:terminal\n")),
5420 "Missing deny pattern option"
5421 );
5422 }
5423}