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