thread_view.rs

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