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