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