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