thread_view.rs

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