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