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