thread_view.rs

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