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