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