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