1use acp_thread::{
2 AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
3 AssistantMessageChunk, AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus,
4 ToolCall, ToolCallContent, ToolCallStatus, UserMessageId,
5};
6use acp_thread::{AgentConnection, Plan};
7use action_log::{ActionLog, ActionLogTelemetry};
8use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
9use agent_client_protocol::{self as acp, PromptCapabilities};
10use agent_servers::{AgentServer, AgentServerDelegate};
11use agent_settings::{AgentProfileId, AgentSettings};
12use anyhow::{Result, anyhow};
13use arrayvec::ArrayVec;
14use audio::{Audio, Sound};
15use buffer_diff::BufferDiff;
16use client::zed_urls;
17use collections::{HashMap, HashSet};
18use editor::scroll::Autoscroll;
19use editor::{
20 Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
21};
22use feature_flags::{AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt};
23use file_icons::FileIcons;
24use fs::Fs;
25use futures::FutureExt as _;
26use gpui::{
27 Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
28 CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
29 ListOffset, ListState, ObjectFit, PlatformDisplay, ScrollHandle, SharedString, StyleRefinement,
30 Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
31 WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point,
32 pulsating_between,
33};
34use language::Buffer;
35use language_model::LanguageModelRegistry;
36use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
37use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
38use prompt_store::{PromptId, PromptStore};
39use rope::Point;
40use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
41use std::cell::RefCell;
42use std::path::Path;
43use std::sync::Arc;
44use std::time::Instant;
45use std::{collections::BTreeMap, rc::Rc, time::Duration};
46use terminal_view::terminal_panel::TerminalPanel;
47use text::{Anchor, ToPoint as _};
48use theme::{AgentFontSize, ThemeSettings};
49use ui::{
50 Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, DecoratedIcon,
51 DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, KeyBinding,
52 PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
53 right_click_menu,
54};
55use util::defer;
56use util::{ResultExt, size::format_file_size, time::duration_alt_display};
57use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
58use zed_actions::agent::{Chat, ToggleModelSelector};
59use zed_actions::assistant::OpenRulesLibrary;
60
61use super::config_options::ConfigOptionsView;
62use super::entry_view_state::EntryViewState;
63use super::thread_history::AcpThreadHistory;
64use crate::acp::AcpModelSelectorPopover;
65use crate::acp::ModeSelector;
66use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
67use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
68use crate::agent_diff::AgentDiff;
69use crate::profile_selector::{ProfileProvider, ProfileSelector};
70use crate::ui::{AgentNotification, AgentNotificationEvent};
71use crate::{
72 AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
73 CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
74 OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, SelectPermissionGranularity,
75 SendImmediately, SendNextQueuedMessage, ToggleProfileSelector,
76};
77
78const MAX_COLLAPSED_LINES: usize = 3;
79const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
80const TOKEN_THRESHOLD: u64 = 250;
81
82#[derive(Copy, Clone, Debug, PartialEq, Eq)]
83enum ThreadFeedback {
84 Positive,
85 Negative,
86}
87
88#[derive(Debug)]
89enum ThreadError {
90 PaymentRequired,
91 Refusal,
92 AuthenticationRequired(SharedString),
93 Other(SharedString),
94}
95
96impl ThreadError {
97 fn from_err(error: anyhow::Error, agent: &Rc<dyn AgentServer>) -> Self {
98 if error.is::<language_model::PaymentRequiredError>() {
99 Self::PaymentRequired
100 } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
101 && acp_error.code == acp::ErrorCode::AuthRequired
102 {
103 Self::AuthenticationRequired(acp_error.message.clone().into())
104 } else {
105 let string = format!("{:#}", error);
106 // TODO: we should have Gemini return better errors here.
107 if agent.clone().downcast::<agent_servers::Gemini>().is_some()
108 && string.contains("Could not load the default credentials")
109 || string.contains("API key not valid")
110 || string.contains("Request had invalid authentication credentials")
111 {
112 Self::AuthenticationRequired(string.into())
113 } else {
114 Self::Other(string.into())
115 }
116 }
117 }
118}
119
120impl ProfileProvider for Entity<agent::Thread> {
121 fn profile_id(&self, cx: &App) -> AgentProfileId {
122 self.read(cx).profile().clone()
123 }
124
125 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
126 self.update(cx, |thread, cx| {
127 // Apply the profile and let the thread swap to its default model.
128 thread.set_profile(profile_id, cx);
129 });
130 }
131
132 fn profiles_supported(&self, cx: &App) -> bool {
133 self.read(cx)
134 .model()
135 .is_some_and(|model| model.supports_tools())
136 }
137}
138
139#[derive(Default)]
140struct ThreadFeedbackState {
141 feedback: Option<ThreadFeedback>,
142 comments_editor: Option<Entity<Editor>>,
143}
144
145impl ThreadFeedbackState {
146 pub fn submit(
147 &mut self,
148 thread: Entity<AcpThread>,
149 feedback: ThreadFeedback,
150 window: &mut Window,
151 cx: &mut App,
152 ) {
153 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
154 return;
155 };
156
157 if self.feedback == Some(feedback) {
158 return;
159 }
160
161 self.feedback = Some(feedback);
162 match feedback {
163 ThreadFeedback::Positive => {
164 self.comments_editor = None;
165 }
166 ThreadFeedback::Negative => {
167 self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
168 }
169 }
170 let session_id = thread.read(cx).session_id().clone();
171 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
172 let task = telemetry.thread_data(&session_id, cx);
173 let rating = match feedback {
174 ThreadFeedback::Positive => "positive",
175 ThreadFeedback::Negative => "negative",
176 };
177 cx.background_spawn(async move {
178 let thread = task.await?;
179 telemetry::event!(
180 "Agent Thread Rated",
181 agent = agent_telemetry_id,
182 session_id = session_id,
183 rating = rating,
184 thread = thread
185 );
186 anyhow::Ok(())
187 })
188 .detach_and_log_err(cx);
189 }
190
191 pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
192 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
193 return;
194 };
195
196 let Some(comments) = self
197 .comments_editor
198 .as_ref()
199 .map(|editor| editor.read(cx).text(cx))
200 .filter(|text| !text.trim().is_empty())
201 else {
202 return;
203 };
204
205 self.comments_editor.take();
206
207 let session_id = thread.read(cx).session_id().clone();
208 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
209 let task = telemetry.thread_data(&session_id, cx);
210 cx.background_spawn(async move {
211 let thread = task.await?;
212 telemetry::event!(
213 "Agent Thread Feedback Comments",
214 agent = agent_telemetry_id,
215 session_id = session_id,
216 comments = comments,
217 thread = thread
218 );
219 anyhow::Ok(())
220 })
221 .detach_and_log_err(cx);
222 }
223
224 pub fn clear(&mut self) {
225 *self = Self::default()
226 }
227
228 pub fn dismiss_comments(&mut self) {
229 self.comments_editor.take();
230 }
231
232 fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
233 let buffer = cx.new(|cx| {
234 let empty_string = String::new();
235 MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
236 });
237
238 let editor = cx.new(|cx| {
239 let mut editor = Editor::new(
240 editor::EditorMode::AutoHeight {
241 min_lines: 1,
242 max_lines: Some(4),
243 },
244 buffer,
245 None,
246 window,
247 cx,
248 );
249 editor.set_placeholder_text(
250 "What went wrong? Share your feedback so we can improve.",
251 window,
252 cx,
253 );
254 editor
255 });
256
257 editor.read(cx).focus_handle(cx).focus(window, cx);
258 editor
259 }
260}
261
262#[derive(Default, Clone, Copy)]
263struct DiffStats {
264 lines_added: u32,
265 lines_removed: u32,
266}
267
268impl DiffStats {
269 fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
270 let mut stats = DiffStats::default();
271 let diff_snapshot = diff.snapshot(cx);
272 let buffer_snapshot = buffer.snapshot();
273 let base_text = diff_snapshot.base_text();
274
275 for hunk in diff_snapshot.hunks(&buffer_snapshot) {
276 let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
277 stats.lines_added += added_rows;
278
279 let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
280 let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
281 let removed_rows = base_end.saturating_sub(base_start);
282 stats.lines_removed += removed_rows;
283 }
284
285 stats
286 }
287
288 fn all_files(changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>, cx: &App) -> Self {
289 let mut total = DiffStats::default();
290 for (buffer, diff) in changed_buffers {
291 let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
292 total.lines_added += stats.lines_added;
293 total.lines_removed += stats.lines_removed;
294 }
295 total
296 }
297}
298
299pub struct AcpThreadView {
300 agent: Rc<dyn AgentServer>,
301 agent_server_store: Entity<AgentServerStore>,
302 workspace: WeakEntity<Workspace>,
303 project: Entity<Project>,
304 thread_state: ThreadState,
305 permission_dropdown_handle: PopoverMenuHandle<ContextMenu>,
306 /// Tracks the selected granularity index for each tool call's permission dropdown.
307 /// The index corresponds to the position in the allow_options list.
308 /// Default is the last option (index pointing to "Only this time").
309 selected_permission_granularity: HashMap<acp::ToolCallId, usize>,
310 login: Option<task::SpawnInTerminal>,
311 recent_history_entries: Vec<AgentSessionInfo>,
312 history: Entity<AcpThreadHistory>,
313 _history_subscription: Subscription,
314 hovered_recent_history_item: Option<usize>,
315 entry_view_state: Entity<EntryViewState>,
316 message_editor: Entity<MessageEditor>,
317 focus_handle: FocusHandle,
318 model_selector: Option<Entity<AcpModelSelectorPopover>>,
319 config_options_view: Option<Entity<ConfigOptionsView>>,
320 profile_selector: Option<Entity<ProfileSelector>>,
321 notifications: Vec<WindowHandle<AgentNotification>>,
322 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
323 thread_retry_status: Option<RetryStatus>,
324 thread_error: Option<ThreadError>,
325 thread_error_markdown: Option<Entity<Markdown>>,
326 token_limit_callout_dismissed: bool,
327 thread_feedback: ThreadFeedbackState,
328 list_state: ListState,
329 auth_task: Option<Task<()>>,
330 /// Tracks which tool calls have their content/output expanded.
331 /// Used for showing/hiding tool call results, terminal output, etc.
332 expanded_tool_calls: HashSet<acp::ToolCallId>,
333 /// Tracks which terminal commands have their command text expanded.
334 /// This is separate from `expanded_tool_calls` because command text expansion
335 /// (showing all lines of a long command) is independent from output expansion
336 /// (showing the terminal output).
337 expanded_terminal_commands: HashSet<acp::ToolCallId>,
338 expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
339 expanded_thinking_blocks: HashSet<(usize, usize)>,
340 expanded_subagents: HashSet<acp::SessionId>,
341 subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
342 edits_expanded: bool,
343 plan_expanded: bool,
344 queue_expanded: bool,
345 editor_expanded: bool,
346 should_be_following: bool,
347 editing_message: Option<usize>,
348 discarded_partial_edits: HashSet<acp::ToolCallId>,
349 prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
350 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
351 is_loading_contents: bool,
352 new_server_version_available: Option<SharedString>,
353 resume_thread_metadata: Option<AgentSessionInfo>,
354 _cancel_task: Option<Task<()>>,
355 _subscriptions: [Subscription; 5],
356 show_codex_windows_warning: bool,
357 in_flight_prompt: Option<Vec<acp::ContentBlock>>,
358 message_queue: Vec<QueuedMessage>,
359 skip_queue_processing_count: usize,
360 user_interrupted_generation: bool,
361 turn_tokens: Option<u64>,
362 last_turn_tokens: Option<u64>,
363 turn_started_at: Option<Instant>,
364 last_turn_duration: Option<Duration>,
365 turn_generation: usize,
366 _turn_timer_task: Option<Task<()>>,
367 hovered_edited_file_buttons: Option<usize>,
368}
369
370struct QueuedMessage {
371 content: Vec<acp::ContentBlock>,
372 tracked_buffers: Vec<Entity<Buffer>>,
373}
374
375enum ThreadState {
376 Loading(Entity<LoadingView>),
377 Ready {
378 thread: Entity<AcpThread>,
379 title_editor: Option<Entity<Editor>>,
380 mode_selector: Option<Entity<ModeSelector>>,
381 _subscriptions: Vec<Subscription>,
382 },
383 LoadError(LoadError),
384 Unauthenticated {
385 connection: Rc<dyn AgentConnection>,
386 description: Option<Entity<Markdown>>,
387 configuration_view: Option<AnyView>,
388 pending_auth_method: Option<acp::AuthMethodId>,
389 _subscription: Option<Subscription>,
390 },
391}
392
393struct LoadingView {
394 title: SharedString,
395 _load_task: Task<()>,
396 _update_title_task: Task<anyhow::Result<()>>,
397}
398
399impl AcpThreadView {
400 pub fn new(
401 agent: Rc<dyn AgentServer>,
402 resume_thread: Option<AgentSessionInfo>,
403 summarize_thread: Option<AgentSessionInfo>,
404 workspace: WeakEntity<Workspace>,
405 project: Entity<Project>,
406 thread_store: Option<Entity<ThreadStore>>,
407 prompt_store: Option<Entity<PromptStore>>,
408 history: Entity<AcpThreadHistory>,
409 track_load_event: bool,
410 window: &mut Window,
411 cx: &mut Context<Self>,
412 ) -> Self {
413 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
414 let available_commands = Rc::new(RefCell::new(vec![]));
415
416 let agent_server_store = project.read(cx).agent_server_store().clone();
417 let agent_display_name = agent_server_store
418 .read(cx)
419 .agent_display_name(&ExternalAgentServerName(agent.name()))
420 .unwrap_or_else(|| agent.name());
421
422 let placeholder = placeholder_text(agent_display_name.as_ref(), false);
423
424 let message_editor = cx.new(|cx| {
425 let mut editor = MessageEditor::new(
426 workspace.clone(),
427 project.downgrade(),
428 thread_store.clone(),
429 history.downgrade(),
430 prompt_store.clone(),
431 prompt_capabilities.clone(),
432 available_commands.clone(),
433 agent.name(),
434 &placeholder,
435 editor::EditorMode::AutoHeight {
436 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
437 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
438 },
439 window,
440 cx,
441 );
442 if let Some(entry) = summarize_thread {
443 editor.insert_thread_summary(entry, window, cx);
444 }
445 editor
446 });
447
448 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
449
450 let entry_view_state = cx.new(|_| {
451 EntryViewState::new(
452 workspace.clone(),
453 project.downgrade(),
454 thread_store.clone(),
455 history.downgrade(),
456 prompt_store.clone(),
457 prompt_capabilities.clone(),
458 available_commands.clone(),
459 agent.name(),
460 )
461 });
462
463 let subscriptions = [
464 cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
465 cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
466 cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
467 cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
468 cx.subscribe_in(
469 &agent_server_store,
470 window,
471 Self::handle_agent_servers_updated,
472 ),
473 ];
474
475 cx.on_release(|this, cx| {
476 for window in this.notifications.drain(..) {
477 window
478 .update(cx, |_, window, _| {
479 window.remove_window();
480 })
481 .ok();
482 }
483 })
484 .detach();
485
486 let show_codex_windows_warning = cfg!(windows)
487 && project.read(cx).is_local()
488 && agent.clone().downcast::<agent_servers::Codex>().is_some();
489
490 let recent_history_entries = history.read(cx).get_recent_sessions(3);
491 let history_subscription = cx.observe(&history, |this, history, cx| {
492 this.update_recent_history_from_cache(&history, cx);
493 });
494
495 Self {
496 agent: agent.clone(),
497 agent_server_store,
498 workspace: workspace.clone(),
499 project: project.clone(),
500 entry_view_state,
501 permission_dropdown_handle: PopoverMenuHandle::default(),
502 selected_permission_granularity: HashMap::default(),
503 thread_state: Self::initial_state(
504 agent.clone(),
505 resume_thread.clone(),
506 workspace.clone(),
507 project.clone(),
508 track_load_event,
509 window,
510 cx,
511 ),
512 login: None,
513 message_editor,
514 model_selector: None,
515 config_options_view: None,
516 profile_selector: None,
517 notifications: Vec::new(),
518 notification_subscriptions: HashMap::default(),
519 list_state: list_state,
520 thread_retry_status: None,
521 thread_error: None,
522 thread_error_markdown: None,
523 token_limit_callout_dismissed: false,
524 thread_feedback: Default::default(),
525 auth_task: None,
526 expanded_tool_calls: HashSet::default(),
527 expanded_terminal_commands: HashSet::default(),
528 expanded_tool_call_raw_inputs: HashSet::default(),
529 expanded_thinking_blocks: HashSet::default(),
530 expanded_subagents: HashSet::default(),
531 subagent_scroll_handles: RefCell::new(HashMap::default()),
532 editing_message: None,
533 edits_expanded: false,
534 plan_expanded: false,
535 queue_expanded: true,
536 discarded_partial_edits: HashSet::default(),
537 prompt_capabilities,
538 available_commands,
539 editor_expanded: false,
540 should_be_following: false,
541 recent_history_entries,
542 history,
543 _history_subscription: history_subscription,
544 hovered_recent_history_item: None,
545 is_loading_contents: false,
546 _subscriptions: subscriptions,
547 _cancel_task: None,
548 focus_handle: cx.focus_handle(),
549 new_server_version_available: None,
550 resume_thread_metadata: resume_thread,
551 show_codex_windows_warning,
552 in_flight_prompt: None,
553 message_queue: Vec::new(),
554 skip_queue_processing_count: 0,
555 user_interrupted_generation: false,
556 turn_tokens: None,
557 last_turn_tokens: None,
558 turn_started_at: None,
559 last_turn_duration: None,
560 turn_generation: 0,
561 _turn_timer_task: None,
562 hovered_edited_file_buttons: None,
563 }
564 }
565
566 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
567 self.thread_state = Self::initial_state(
568 self.agent.clone(),
569 self.resume_thread_metadata.clone(),
570 self.workspace.clone(),
571 self.project.clone(),
572 true,
573 window,
574 cx,
575 );
576 self.available_commands.replace(vec![]);
577 self.new_server_version_available.take();
578 self.message_queue.clear();
579 self.recent_history_entries.clear();
580 self.turn_tokens = None;
581 self.last_turn_tokens = None;
582 self.turn_started_at = None;
583 self.last_turn_duration = None;
584 self._turn_timer_task = None;
585 cx.notify();
586 }
587
588 fn initial_state(
589 agent: Rc<dyn AgentServer>,
590 resume_thread: Option<AgentSessionInfo>,
591 workspace: WeakEntity<Workspace>,
592 project: Entity<Project>,
593 track_load_event: bool,
594 window: &mut Window,
595 cx: &mut Context<Self>,
596 ) -> ThreadState {
597 if project.read(cx).is_via_collab()
598 && agent.clone().downcast::<NativeAgentServer>().is_none()
599 {
600 return ThreadState::LoadError(LoadError::Other(
601 "External agents are not yet supported in shared projects.".into(),
602 ));
603 }
604 let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
605 // Pick the first non-single-file worktree for the root directory if there are any,
606 // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
607 worktrees.sort_by(|l, r| {
608 l.read(cx)
609 .is_single_file()
610 .cmp(&r.read(cx).is_single_file())
611 });
612 let root_dir = worktrees
613 .into_iter()
614 .filter_map(|worktree| {
615 if worktree.read(cx).is_single_file() {
616 Some(worktree.read(cx).abs_path().parent()?.into())
617 } else {
618 Some(worktree.read(cx).abs_path())
619 }
620 })
621 .next();
622 let (status_tx, mut status_rx) = watch::channel("Loading…".into());
623 let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
624 let delegate = AgentServerDelegate::new(
625 project.read(cx).agent_server_store().clone(),
626 project.clone(),
627 Some(status_tx),
628 Some(new_version_available_tx),
629 );
630
631 let connect_task = agent.connect(root_dir.as_deref(), delegate, cx);
632 let load_task = cx.spawn_in(window, async move |this, cx| {
633 let connection = match connect_task.await {
634 Ok((connection, login)) => {
635 this.update(cx, |this, _| this.login = login).ok();
636 connection
637 }
638 Err(err) => {
639 this.update_in(cx, |this, window, cx| {
640 if err.downcast_ref::<LoadError>().is_some() {
641 this.handle_load_error(err, window, cx);
642 } else {
643 this.handle_thread_error(err, cx);
644 }
645 cx.notify();
646 })
647 .log_err();
648 return;
649 }
650 };
651
652 if track_load_event {
653 telemetry::event!("Agent Thread Started", agent = connection.telemetry_id());
654 }
655
656 let result = if let Some(native_agent) = connection
657 .clone()
658 .downcast::<agent::NativeAgentConnection>()
659 && let Some(resume) = resume_thread.clone()
660 {
661 cx.update(|_, cx| {
662 native_agent
663 .0
664 .update(cx, |agent, cx| agent.open_thread(resume.session_id, cx))
665 })
666 .log_err()
667 } else {
668 let root_dir = root_dir.unwrap_or(paths::home_dir().as_path().into());
669 cx.update(|_, cx| {
670 connection
671 .clone()
672 .new_thread(project.clone(), &root_dir, cx)
673 })
674 .log_err()
675 };
676
677 let Some(result) = result else {
678 return;
679 };
680
681 let result = match result.await {
682 Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
683 Ok(err) => {
684 cx.update(|window, cx| {
685 Self::handle_auth_required(this, err, agent, connection, window, cx)
686 })
687 .log_err();
688 return;
689 }
690 Err(err) => Err(err),
691 },
692 Ok(thread) => Ok(thread),
693 };
694
695 this.update_in(cx, |this, window, cx| {
696 match result {
697 Ok(thread) => {
698 let action_log = thread.read(cx).action_log().clone();
699
700 this.prompt_capabilities
701 .replace(thread.read(cx).prompt_capabilities());
702
703 let count = thread.read(cx).entries().len();
704 this.entry_view_state.update(cx, |view_state, cx| {
705 for ix in 0..count {
706 view_state.sync_entry(ix, &thread, window, cx);
707 }
708 this.list_state.splice_focusable(
709 0..0,
710 (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
711 );
712 });
713
714 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
715
716 let connection = thread.read(cx).connection().clone();
717 let session_id = thread.read(cx).session_id().clone();
718 let session_list = connection.session_list(cx);
719 this.history.update(cx, |history, cx| {
720 history.set_session_list(session_list, cx);
721 });
722
723 // Check for config options first
724 // Config options take precedence over legacy mode/model selectors
725 // (feature flag gating happens at the data layer)
726 let config_options_provider =
727 connection.session_config_options(&session_id, cx);
728
729 let mode_selector;
730 if let Some(config_options) = config_options_provider {
731 // Use config options - don't create mode_selector or model_selector
732 let agent_server = this.agent.clone();
733 let fs = this.project.read(cx).fs().clone();
734 this.config_options_view = Some(cx.new(|cx| {
735 ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
736 }));
737 this.model_selector = None;
738 mode_selector = None;
739 } else {
740 // Fall back to legacy mode/model selectors
741 this.config_options_view = None;
742 this.model_selector =
743 connection.model_selector(&session_id).map(|selector| {
744 let agent_server = this.agent.clone();
745 let fs = this.project.read(cx).fs().clone();
746 cx.new(|cx| {
747 AcpModelSelectorPopover::new(
748 selector,
749 agent_server,
750 fs,
751 PopoverMenuHandle::default(),
752 this.focus_handle(cx),
753 window,
754 cx,
755 )
756 })
757 });
758
759 mode_selector =
760 connection
761 .session_modes(&session_id, cx)
762 .map(|session_modes| {
763 let fs = this.project.read(cx).fs().clone();
764 let focus_handle = this.focus_handle(cx);
765 cx.new(|_cx| {
766 ModeSelector::new(
767 session_modes,
768 this.agent.clone(),
769 fs,
770 focus_handle,
771 )
772 })
773 });
774 }
775
776 let mut subscriptions = vec![
777 cx.subscribe_in(&thread, window, Self::handle_thread_event),
778 cx.observe(&action_log, |_, _, cx| cx.notify()),
779 ];
780
781 let title_editor =
782 if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
783 let editor = cx.new(|cx| {
784 let mut editor = Editor::single_line(window, cx);
785 editor.set_text(thread.read(cx).title(), window, cx);
786 editor
787 });
788 subscriptions.push(cx.subscribe_in(
789 &editor,
790 window,
791 Self::handle_title_editor_event,
792 ));
793 Some(editor)
794 } else {
795 None
796 };
797
798 this.thread_state = ThreadState::Ready {
799 thread,
800 title_editor,
801 mode_selector,
802 _subscriptions: subscriptions,
803 };
804
805 this.profile_selector = this.as_native_thread(cx).map(|thread| {
806 cx.new(|cx| {
807 ProfileSelector::new(
808 <dyn Fs>::global(cx),
809 Arc::new(thread.clone()),
810 this.focus_handle(cx),
811 cx,
812 )
813 })
814 });
815
816 this.message_editor.focus_handle(cx).focus(window, cx);
817
818 cx.notify();
819 }
820 Err(err) => {
821 this.handle_load_error(err, window, cx);
822 }
823 };
824 })
825 .log_err();
826 });
827
828 cx.spawn(async move |this, cx| {
829 while let Ok(new_version) = new_version_available_rx.recv().await {
830 if let Some(new_version) = new_version {
831 this.update(cx, |this, cx| {
832 this.new_server_version_available = Some(new_version.into());
833 cx.notify();
834 })
835 .ok();
836 }
837 }
838 })
839 .detach();
840
841 let loading_view = cx.new(|cx| {
842 let update_title_task = cx.spawn(async move |this, cx| {
843 loop {
844 let status = status_rx.recv().await?;
845 this.update(cx, |this: &mut LoadingView, cx| {
846 this.title = status;
847 cx.notify();
848 })?;
849 }
850 });
851
852 LoadingView {
853 title: "Loading…".into(),
854 _load_task: load_task,
855 _update_title_task: update_title_task,
856 }
857 });
858
859 ThreadState::Loading(loading_view)
860 }
861
862 fn handle_auth_required(
863 this: WeakEntity<Self>,
864 err: AuthRequired,
865 agent: Rc<dyn AgentServer>,
866 connection: Rc<dyn AgentConnection>,
867 window: &mut Window,
868 cx: &mut App,
869 ) {
870 let agent_name = agent.name();
871 let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
872 let registry = LanguageModelRegistry::global(cx);
873
874 let sub = window.subscribe(®istry, cx, {
875 let provider_id = provider_id.clone();
876 let this = this.clone();
877 move |_, ev, window, cx| {
878 if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
879 && &provider_id == updated_provider_id
880 && LanguageModelRegistry::global(cx)
881 .read(cx)
882 .provider(&provider_id)
883 .map_or(false, |provider| provider.is_authenticated(cx))
884 {
885 this.update(cx, |this, cx| {
886 this.reset(window, cx);
887 })
888 .ok();
889 }
890 }
891 });
892
893 let view = registry.read(cx).provider(&provider_id).map(|provider| {
894 provider.configuration_view(
895 language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
896 window,
897 cx,
898 )
899 });
900
901 (view, Some(sub))
902 } else {
903 (None, None)
904 };
905
906 this.update(cx, |this, cx| {
907 this.thread_state = ThreadState::Unauthenticated {
908 pending_auth_method: None,
909 connection,
910 configuration_view,
911 description: err
912 .description
913 .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
914 _subscription: subscription,
915 };
916 if this.message_editor.focus_handle(cx).is_focused(window) {
917 this.focus_handle.focus(window, cx)
918 }
919 cx.notify();
920 })
921 .ok();
922 }
923
924 fn handle_load_error(
925 &mut self,
926 err: anyhow::Error,
927 window: &mut Window,
928 cx: &mut Context<Self>,
929 ) {
930 if let Some(load_err) = err.downcast_ref::<LoadError>() {
931 self.thread_state = ThreadState::LoadError(load_err.clone());
932 } else {
933 self.thread_state =
934 ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into()))
935 }
936 if self.message_editor.focus_handle(cx).is_focused(window) {
937 self.focus_handle.focus(window, cx)
938 }
939 cx.notify();
940 }
941
942 fn handle_agent_servers_updated(
943 &mut self,
944 _agent_server_store: &Entity<project::AgentServerStore>,
945 _event: &project::AgentServersUpdated,
946 window: &mut Window,
947 cx: &mut Context<Self>,
948 ) {
949 // If we're in a LoadError state OR have a thread_error set (which can happen
950 // when agent.connect() fails during loading), retry loading the thread.
951 // This handles the case where a thread is restored before authentication completes.
952 let should_retry =
953 matches!(&self.thread_state, ThreadState::LoadError(_)) || self.thread_error.is_some();
954
955 if should_retry {
956 self.thread_error = None;
957 self.thread_error_markdown = None;
958 self.reset(window, cx);
959 }
960 }
961
962 pub fn workspace(&self) -> &WeakEntity<Workspace> {
963 &self.workspace
964 }
965
966 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
967 match &self.thread_state {
968 ThreadState::Ready { thread, .. } => Some(thread),
969 ThreadState::Unauthenticated { .. }
970 | ThreadState::Loading { .. }
971 | ThreadState::LoadError { .. } => None,
972 }
973 }
974
975 pub fn mode_selector(&self) -> Option<&Entity<ModeSelector>> {
976 match &self.thread_state {
977 ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(),
978 ThreadState::Unauthenticated { .. }
979 | ThreadState::Loading { .. }
980 | ThreadState::LoadError { .. } => None,
981 }
982 }
983
984 pub fn title(&self, cx: &App) -> SharedString {
985 match &self.thread_state {
986 ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
987 ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
988 ThreadState::LoadError(error) => match error {
989 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
990 LoadError::FailedToInstall(_) => {
991 format!("Failed to Install {}", self.agent.name()).into()
992 }
993 LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
994 LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
995 },
996 }
997 }
998
999 pub fn title_editor(&self) -> Option<Entity<Editor>> {
1000 if let ThreadState::Ready { title_editor, .. } = &self.thread_state {
1001 title_editor.clone()
1002 } else {
1003 None
1004 }
1005 }
1006
1007 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
1008 self.thread_error.take();
1009 self.thread_retry_status.take();
1010
1011 if let Some(thread) = self.thread() {
1012 self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
1013 }
1014 }
1015
1016 fn share_thread(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1017 let Some(thread) = self.as_native_thread(cx) else {
1018 return;
1019 };
1020
1021 let client = self.project.read(cx).client();
1022 let workspace = self.workspace.clone();
1023 let session_id = thread.read(cx).id().to_string();
1024
1025 let load_task = thread.read(cx).to_db(cx);
1026
1027 cx.spawn(async move |_this, cx| {
1028 let db_thread = load_task.await;
1029
1030 let shared_thread = SharedThread::from_db_thread(&db_thread);
1031 let thread_data = shared_thread.to_bytes()?;
1032 let title = shared_thread.title.to_string();
1033
1034 client
1035 .request(proto::ShareAgentThread {
1036 session_id: session_id.clone(),
1037 title,
1038 thread_data,
1039 })
1040 .await?;
1041
1042 let share_url = client::zed_urls::shared_agent_thread_url(&session_id);
1043
1044 cx.update(|cx| {
1045 if let Some(workspace) = workspace.upgrade() {
1046 workspace.update(cx, |workspace, cx| {
1047 struct ThreadSharedToast;
1048 workspace.show_toast(
1049 Toast::new(
1050 NotificationId::unique::<ThreadSharedToast>(),
1051 "Thread shared!",
1052 )
1053 .on_click(
1054 "Copy URL",
1055 move |_window, cx| {
1056 cx.write_to_clipboard(ClipboardItem::new_string(
1057 share_url.clone(),
1058 ));
1059 },
1060 ),
1061 cx,
1062 );
1063 });
1064 }
1065 });
1066
1067 anyhow::Ok(())
1068 })
1069 .detach_and_log_err(cx);
1070 }
1071
1072 fn sync_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1073 if !self.is_imported_thread(cx) {
1074 return;
1075 }
1076
1077 let Some(thread) = self.thread() else {
1078 return;
1079 };
1080
1081 let Some(session_list) = self
1082 .as_native_connection(cx)
1083 .and_then(|connection| connection.session_list(cx))
1084 .and_then(|list| list.downcast::<NativeAgentSessionList>())
1085 else {
1086 return;
1087 };
1088 let thread_store = session_list.thread_store().clone();
1089
1090 let client = self.project.read(cx).client();
1091 let session_id = thread.read(cx).session_id().clone();
1092
1093 cx.spawn_in(window, async move |this, cx| {
1094 let response = client
1095 .request(proto::GetSharedAgentThread {
1096 session_id: session_id.to_string(),
1097 })
1098 .await?;
1099
1100 let shared_thread = SharedThread::from_bytes(&response.thread_data)?;
1101
1102 let db_thread = shared_thread.to_db_thread();
1103
1104 thread_store
1105 .update(&mut cx.clone(), |store, cx| {
1106 store.save_thread(session_id.clone(), db_thread, cx)
1107 })
1108 .await?;
1109
1110 let thread_metadata = AgentSessionInfo {
1111 session_id,
1112 cwd: None,
1113 title: Some(format!("🔗 {}", response.title).into()),
1114 updated_at: Some(chrono::Utc::now()),
1115 meta: None,
1116 };
1117
1118 this.update_in(cx, |this, window, cx| {
1119 this.resume_thread_metadata = Some(thread_metadata);
1120 this.reset(window, cx);
1121 })?;
1122
1123 this.update_in(cx, |this, _window, cx| {
1124 if let Some(workspace) = this.workspace.upgrade() {
1125 workspace.update(cx, |workspace, cx| {
1126 struct ThreadSyncedToast;
1127 workspace.show_toast(
1128 Toast::new(
1129 NotificationId::unique::<ThreadSyncedToast>(),
1130 "Thread synced with latest version",
1131 )
1132 .autohide(),
1133 cx,
1134 );
1135 });
1136 }
1137 })?;
1138
1139 anyhow::Ok(())
1140 })
1141 .detach_and_log_err(cx);
1142 }
1143
1144 pub fn expand_message_editor(
1145 &mut self,
1146 _: &ExpandMessageEditor,
1147 _window: &mut Window,
1148 cx: &mut Context<Self>,
1149 ) {
1150 self.set_editor_is_expanded(!self.editor_expanded, cx);
1151 cx.stop_propagation();
1152 cx.notify();
1153 }
1154
1155 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
1156 self.editor_expanded = is_expanded;
1157 self.message_editor.update(cx, |editor, cx| {
1158 if is_expanded {
1159 editor.set_mode(
1160 EditorMode::Full {
1161 scale_ui_elements_with_buffer_font_size: false,
1162 show_active_line_background: false,
1163 sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
1164 },
1165 cx,
1166 )
1167 } else {
1168 let agent_settings = AgentSettings::get_global(cx);
1169 editor.set_mode(
1170 EditorMode::AutoHeight {
1171 min_lines: agent_settings.message_editor_min_lines,
1172 max_lines: Some(agent_settings.set_message_editor_max_lines()),
1173 },
1174 cx,
1175 )
1176 }
1177 });
1178 cx.notify();
1179 }
1180
1181 pub fn handle_title_editor_event(
1182 &mut self,
1183 title_editor: &Entity<Editor>,
1184 event: &EditorEvent,
1185 window: &mut Window,
1186 cx: &mut Context<Self>,
1187 ) {
1188 let Some(thread) = self.thread() else { return };
1189
1190 match event {
1191 EditorEvent::BufferEdited => {
1192 let new_title = title_editor.read(cx).text(cx);
1193 thread.update(cx, |thread, cx| {
1194 thread
1195 .set_title(new_title.into(), cx)
1196 .detach_and_log_err(cx);
1197 })
1198 }
1199 EditorEvent::Blurred => {
1200 if title_editor.read(cx).text(cx).is_empty() {
1201 title_editor.update(cx, |editor, cx| {
1202 editor.set_text("New Thread", window, cx);
1203 });
1204 }
1205 }
1206 _ => {}
1207 }
1208 }
1209
1210 pub fn handle_message_editor_event(
1211 &mut self,
1212 _: &Entity<MessageEditor>,
1213 event: &MessageEditorEvent,
1214 window: &mut Window,
1215 cx: &mut Context<Self>,
1216 ) {
1217 match event {
1218 MessageEditorEvent::Send => self.send(window, cx),
1219 MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
1220 MessageEditorEvent::Cancel => self.cancel_generation(cx),
1221 MessageEditorEvent::Focus => {
1222 self.cancel_editing(&Default::default(), window, cx);
1223 }
1224 MessageEditorEvent::LostFocus => {}
1225 }
1226 }
1227
1228 pub fn handle_entry_view_event(
1229 &mut self,
1230 _: &Entity<EntryViewState>,
1231 event: &EntryViewEvent,
1232 window: &mut Window,
1233 cx: &mut Context<Self>,
1234 ) {
1235 match &event.view_event {
1236 ViewEvent::NewDiff(tool_call_id) => {
1237 if AgentSettings::get_global(cx).expand_edit_card {
1238 self.expanded_tool_calls.insert(tool_call_id.clone());
1239 }
1240 }
1241 ViewEvent::NewTerminal(tool_call_id) => {
1242 if AgentSettings::get_global(cx).expand_terminal_card {
1243 self.expanded_tool_calls.insert(tool_call_id.clone());
1244 }
1245 }
1246 ViewEvent::TerminalMovedToBackground(tool_call_id) => {
1247 self.expanded_tool_calls.remove(tool_call_id);
1248 }
1249 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
1250 if let Some(thread) = self.thread()
1251 && let Some(AgentThreadEntry::UserMessage(user_message)) =
1252 thread.read(cx).entries().get(event.entry_index)
1253 && user_message.id.is_some()
1254 {
1255 self.editing_message = Some(event.entry_index);
1256 cx.notify();
1257 }
1258 }
1259 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
1260 if let Some(thread) = self.thread()
1261 && let Some(AgentThreadEntry::UserMessage(user_message)) =
1262 thread.read(cx).entries().get(event.entry_index)
1263 && user_message.id.is_some()
1264 {
1265 if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
1266 self.editing_message = None;
1267 cx.notify();
1268 }
1269 }
1270 }
1271 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {}
1272 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
1273 self.regenerate(event.entry_index, editor.clone(), window, cx);
1274 }
1275 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
1276 self.cancel_editing(&Default::default(), window, cx);
1277 }
1278 }
1279 }
1280
1281 pub fn is_loading(&self) -> bool {
1282 matches!(self.thread_state, ThreadState::Loading { .. })
1283 }
1284
1285 fn resume_chat(&mut self, cx: &mut Context<Self>) {
1286 self.thread_error.take();
1287 let Some(thread) = self.thread() else {
1288 return;
1289 };
1290 if !thread.read(cx).can_resume(cx) {
1291 return;
1292 }
1293
1294 let task = thread.update(cx, |thread, cx| thread.resume(cx));
1295 cx.spawn(async move |this, cx| {
1296 let result = task.await;
1297
1298 this.update(cx, |this, cx| {
1299 if let Err(err) = result {
1300 this.handle_thread_error(err, cx);
1301 }
1302 })
1303 })
1304 .detach();
1305 }
1306
1307 fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1308 let Some(thread) = self.thread() else { return };
1309
1310 if self.is_loading_contents {
1311 return;
1312 }
1313
1314 if thread.read(cx).status() != ThreadStatus::Idle {
1315 self.queue_message(window, cx);
1316 return;
1317 }
1318
1319 let text = self.message_editor.read(cx).text(cx);
1320 let text = text.trim();
1321 if text == "/login" || text == "/logout" {
1322 let ThreadState::Ready { thread, .. } = &self.thread_state else {
1323 return;
1324 };
1325
1326 let connection = thread.read(cx).connection().clone();
1327 let can_login = !connection.auth_methods().is_empty() || self.login.is_some();
1328 // Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
1329 let logout_supported = text == "/logout"
1330 && self
1331 .available_commands
1332 .borrow()
1333 .iter()
1334 .any(|command| command.name == "logout");
1335 if can_login && !logout_supported {
1336 self.message_editor
1337 .update(cx, |editor, cx| editor.clear(window, cx));
1338
1339 let this = cx.weak_entity();
1340 let agent = self.agent.clone();
1341 window.defer(cx, |window, cx| {
1342 Self::handle_auth_required(
1343 this,
1344 AuthRequired::new(),
1345 agent,
1346 connection,
1347 window,
1348 cx,
1349 );
1350 });
1351 cx.notify();
1352 return;
1353 }
1354 }
1355
1356 self.send_impl(self.message_editor.clone(), window, cx)
1357 }
1358
1359 fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1360 let Some(thread) = self.thread() else {
1361 return;
1362 };
1363
1364 if self.is_loading_contents {
1365 return;
1366 }
1367
1368 if thread.read(cx).status() == ThreadStatus::Idle {
1369 self.send_impl(self.message_editor.clone(), window, cx);
1370 return;
1371 }
1372
1373 self.stop_current_and_send_new_message(window, cx);
1374 }
1375
1376 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1377 let Some(thread) = self.thread().cloned() else {
1378 return;
1379 };
1380
1381 self.skip_queue_processing_count = 0;
1382 self.user_interrupted_generation = true;
1383
1384 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1385
1386 cx.spawn_in(window, async move |this, cx| {
1387 cancelled.await;
1388
1389 this.update_in(cx, |this, window, cx| {
1390 this.send_impl(this.message_editor.clone(), window, cx);
1391 })
1392 .ok();
1393 })
1394 .detach();
1395 }
1396
1397 fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
1398 self.turn_generation += 1;
1399 let generation = self.turn_generation;
1400 self.turn_started_at = Some(Instant::now());
1401 self.last_turn_duration = None;
1402 self.last_turn_tokens = None;
1403 self.turn_tokens = Some(0);
1404 self._turn_timer_task = Some(cx.spawn(async move |this, cx| {
1405 loop {
1406 cx.background_executor().timer(Duration::from_secs(1)).await;
1407 if this.update(cx, |_, cx| cx.notify()).is_err() {
1408 break;
1409 }
1410 }
1411 }));
1412 generation
1413 }
1414
1415 fn stop_turn(&mut self, generation: usize) {
1416 if self.turn_generation != generation {
1417 return;
1418 }
1419 self.last_turn_duration = self.turn_started_at.take().map(|started| started.elapsed());
1420 self.last_turn_tokens = self.turn_tokens.take();
1421 self._turn_timer_task = None;
1422 }
1423
1424 fn update_turn_tokens(&mut self, cx: &App) {
1425 if let Some(thread) = self.thread() {
1426 if let Some(usage) = thread.read(cx).token_usage() {
1427 if let Some(ref mut tokens) = self.turn_tokens {
1428 *tokens += usage.output_tokens;
1429 }
1430 }
1431 }
1432 }
1433
1434 fn send_impl(
1435 &mut self,
1436 message_editor: Entity<MessageEditor>,
1437 window: &mut Window,
1438 cx: &mut Context<Self>,
1439 ) {
1440 let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
1441 // Include full contents when using minimal profile
1442 let thread = thread.read(cx);
1443 AgentSettings::get_global(cx)
1444 .profiles
1445 .get(thread.profile())
1446 .is_some_and(|profile| profile.tools.is_empty())
1447 });
1448
1449 let contents = message_editor.update(cx, |message_editor, cx| {
1450 message_editor.contents(full_mention_content, cx)
1451 });
1452
1453 self.thread_error.take();
1454 self.editing_message.take();
1455 self.thread_feedback.clear();
1456
1457 if self.should_be_following {
1458 self.workspace
1459 .update(cx, |workspace, cx| {
1460 workspace.follow(CollaboratorId::Agent, window, cx);
1461 })
1462 .ok();
1463 }
1464
1465 let contents_task = cx.spawn_in(window, async move |this, cx| {
1466 let (contents, tracked_buffers) = contents.await?;
1467
1468 if contents.is_empty() {
1469 return Ok(None);
1470 }
1471
1472 this.update_in(cx, |this, window, cx| {
1473 this.message_editor.update(cx, |message_editor, cx| {
1474 message_editor.clear(window, cx);
1475 });
1476 })?;
1477
1478 Ok(Some((contents, tracked_buffers)))
1479 });
1480
1481 self.send_content(contents_task, window, cx);
1482 }
1483
1484 fn send_content(
1485 &mut self,
1486 contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
1487 window: &mut Window,
1488 cx: &mut Context<Self>,
1489 ) {
1490 let Some(thread) = self.thread() else {
1491 return;
1492 };
1493 let session_id = thread.read(cx).session_id().clone();
1494 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
1495 let thread = thread.downgrade();
1496
1497 self.is_loading_contents = true;
1498 let model_id = self.current_model_id(cx);
1499 let mode_id = self.current_mode_id(cx);
1500 let guard = cx.new(|_| ());
1501 cx.observe_release(&guard, |this, _guard, cx| {
1502 this.is_loading_contents = false;
1503 cx.notify();
1504 })
1505 .detach();
1506
1507 let task = cx.spawn_in(window, async move |this, cx| {
1508 let Some((contents, tracked_buffers)) = contents_task.await? else {
1509 return Ok(());
1510 };
1511
1512 let generation = this.update_in(cx, |this, _window, cx| {
1513 this.in_flight_prompt = Some(contents.clone());
1514 let generation = this.start_turn(cx);
1515 this.set_editor_is_expanded(false, cx);
1516 this.scroll_to_bottom(cx);
1517 generation
1518 })?;
1519
1520 let _stop_turn = defer({
1521 let this = this.clone();
1522 let mut cx = cx.clone();
1523 move || {
1524 this.update(&mut cx, |this, cx| {
1525 this.stop_turn(generation);
1526 cx.notify();
1527 })
1528 .ok();
1529 }
1530 });
1531 let turn_start_time = Instant::now();
1532 let send = thread.update(cx, |thread, cx| {
1533 thread.action_log().update(cx, |action_log, cx| {
1534 for buffer in tracked_buffers {
1535 action_log.buffer_read(buffer, cx)
1536 }
1537 });
1538 drop(guard);
1539
1540 telemetry::event!(
1541 "Agent Message Sent",
1542 agent = agent_telemetry_id,
1543 session = session_id,
1544 model = model_id,
1545 mode = mode_id
1546 );
1547
1548 thread.send(contents, cx)
1549 })?;
1550 let res = send.await;
1551 let turn_time_ms = turn_start_time.elapsed().as_millis();
1552 drop(_stop_turn);
1553 let status = if res.is_ok() {
1554 this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
1555 "success"
1556 } else {
1557 "failure"
1558 };
1559 telemetry::event!(
1560 "Agent Turn Completed",
1561 agent = agent_telemetry_id,
1562 session = session_id,
1563 model = model_id,
1564 mode = mode_id,
1565 status,
1566 turn_time_ms,
1567 );
1568 res
1569 });
1570
1571 cx.spawn(async move |this, cx| {
1572 if let Err(err) = task.await {
1573 this.update(cx, |this, cx| {
1574 this.handle_thread_error(err, cx);
1575 })
1576 .ok();
1577 } else {
1578 this.update(cx, |this, cx| {
1579 this.should_be_following = this
1580 .workspace
1581 .update(cx, |workspace, _| {
1582 workspace.is_being_followed(CollaboratorId::Agent)
1583 })
1584 .unwrap_or_default();
1585 })
1586 .ok();
1587 }
1588 })
1589 .detach();
1590 }
1591
1592 fn queue_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1593 let is_idle = self
1594 .thread()
1595 .map(|t| t.read(cx).status() == acp_thread::ThreadStatus::Idle)
1596 .unwrap_or(true);
1597
1598 if is_idle {
1599 self.send_impl(self.message_editor.clone(), window, cx);
1600 return;
1601 }
1602
1603 let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
1604 let thread = thread.read(cx);
1605 AgentSettings::get_global(cx)
1606 .profiles
1607 .get(thread.profile())
1608 .is_some_and(|profile| profile.tools.is_empty())
1609 });
1610
1611 let contents = self.message_editor.update(cx, |message_editor, cx| {
1612 message_editor.contents(full_mention_content, cx)
1613 });
1614
1615 let message_editor = self.message_editor.clone();
1616
1617 cx.spawn_in(window, async move |this, cx| {
1618 let (content, tracked_buffers) = contents.await?;
1619
1620 if content.is_empty() {
1621 return Ok::<(), anyhow::Error>(());
1622 }
1623
1624 this.update_in(cx, |this, window, cx| {
1625 this.message_queue.push(QueuedMessage {
1626 content,
1627 tracked_buffers,
1628 });
1629 message_editor.update(cx, |message_editor, cx| {
1630 message_editor.clear(window, cx);
1631 });
1632 cx.notify();
1633 })?;
1634 Ok(())
1635 })
1636 .detach_and_log_err(cx);
1637 }
1638
1639 fn send_queued_message_at_index(
1640 &mut self,
1641 index: usize,
1642 is_send_now: bool,
1643 window: &mut Window,
1644 cx: &mut Context<Self>,
1645 ) {
1646 if index >= self.message_queue.len() {
1647 return;
1648 }
1649
1650 let queued = self.message_queue.remove(index);
1651 let content = queued.content;
1652 let tracked_buffers = queued.tracked_buffers;
1653
1654 let Some(thread) = self.thread().cloned() else {
1655 return;
1656 };
1657
1658 // Only increment skip count for "Send Now" operations (out-of-order sends)
1659 // Normal auto-processing from the Stopped handler doesn't need to skip
1660 if is_send_now {
1661 let is_generating = thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
1662 self.skip_queue_processing_count += if is_generating { 2 } else { 1 };
1663 }
1664
1665 // Ensure we don't end up with multiple concurrent generations
1666 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1667
1668 let should_be_following = self.should_be_following;
1669 let workspace = self.workspace.clone();
1670
1671 let contents_task = cx.spawn_in(window, async move |_this, cx| {
1672 cancelled.await;
1673 if should_be_following {
1674 workspace
1675 .update_in(cx, |workspace, window, cx| {
1676 workspace.follow(CollaboratorId::Agent, window, cx);
1677 })
1678 .ok();
1679 }
1680
1681 Ok(Some((content, tracked_buffers)))
1682 });
1683
1684 self.send_content(contents_task, window, cx);
1685 }
1686
1687 fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1688 let Some(thread) = self.thread().cloned() else {
1689 return;
1690 };
1691
1692 if let Some(index) = self.editing_message.take()
1693 && let Some(editor) = self
1694 .entry_view_state
1695 .read(cx)
1696 .entry(index)
1697 .and_then(|e| e.message_editor())
1698 .cloned()
1699 {
1700 editor.update(cx, |editor, cx| {
1701 if let Some(user_message) = thread
1702 .read(cx)
1703 .entries()
1704 .get(index)
1705 .and_then(|e| e.user_message())
1706 {
1707 editor.set_message(user_message.chunks.clone(), window, cx);
1708 }
1709 })
1710 };
1711 self.focus_handle(cx).focus(window, cx);
1712 cx.notify();
1713 }
1714
1715 fn regenerate(
1716 &mut self,
1717 entry_ix: usize,
1718 message_editor: Entity<MessageEditor>,
1719 window: &mut Window,
1720 cx: &mut Context<Self>,
1721 ) {
1722 let Some(thread) = self.thread().cloned() else {
1723 return;
1724 };
1725 if self.is_loading_contents {
1726 return;
1727 }
1728
1729 let Some(user_message_id) = thread.update(cx, |thread, _| {
1730 thread.entries().get(entry_ix)?.user_message()?.id.clone()
1731 }) else {
1732 return;
1733 };
1734
1735 cx.spawn_in(window, async move |this, cx| {
1736 // Check if there are any edits from prompts before the one being regenerated.
1737 //
1738 // If there are, we keep/accept them since we're not regenerating the prompt that created them.
1739 //
1740 // If editing the prompt that generated the edits, they are auto-rejected
1741 // through the `rewind` function in the `acp_thread`.
1742 let has_earlier_edits = thread.read_with(cx, |thread, _| {
1743 thread
1744 .entries()
1745 .iter()
1746 .take(entry_ix)
1747 .any(|entry| entry.diffs().next().is_some())
1748 });
1749
1750 if has_earlier_edits {
1751 thread.update(cx, |thread, cx| {
1752 thread.action_log().update(cx, |action_log, cx| {
1753 action_log.keep_all_edits(None, cx);
1754 });
1755 });
1756 }
1757
1758 thread
1759 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))
1760 .await?;
1761 this.update_in(cx, |this, window, cx| {
1762 this.send_impl(message_editor, window, cx);
1763 this.focus_handle(cx).focus(window, cx);
1764 })?;
1765 anyhow::Ok(())
1766 })
1767 .detach_and_log_err(cx);
1768 }
1769
1770 fn open_edited_buffer(
1771 &mut self,
1772 buffer: &Entity<Buffer>,
1773 window: &mut Window,
1774 cx: &mut Context<Self>,
1775 ) {
1776 let Some(thread) = self.thread() else {
1777 return;
1778 };
1779
1780 let Some(diff) =
1781 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
1782 else {
1783 return;
1784 };
1785
1786 diff.update(cx, |diff, cx| {
1787 diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
1788 })
1789 }
1790
1791 fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1792 let Some(thread) = self.as_native_thread(cx) else {
1793 return;
1794 };
1795 let project_context = thread.read(cx).project_context().read(cx);
1796
1797 let project_entry_ids = project_context
1798 .worktrees
1799 .iter()
1800 .flat_map(|worktree| worktree.rules_file.as_ref())
1801 .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
1802 .collect::<Vec<_>>();
1803
1804 self.workspace
1805 .update(cx, move |workspace, cx| {
1806 // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
1807 // files clear. For example, if rules file 1 is already open but rules file 2 is not,
1808 // this would open and focus rules file 2 in a tab that is not next to rules file 1.
1809 let project = workspace.project().read(cx);
1810 let project_paths = project_entry_ids
1811 .into_iter()
1812 .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
1813 .collect::<Vec<_>>();
1814 for project_path in project_paths {
1815 workspace
1816 .open_path(project_path, None, true, window, cx)
1817 .detach_and_log_err(cx);
1818 }
1819 })
1820 .ok();
1821 }
1822
1823 fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
1824 self.thread_error = Some(ThreadError::from_err(error, &self.agent));
1825 cx.notify();
1826 }
1827
1828 fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
1829 self.thread_error = None;
1830 self.thread_error_markdown = None;
1831 self.token_limit_callout_dismissed = true;
1832 cx.notify();
1833 }
1834
1835 fn handle_thread_event(
1836 &mut self,
1837 thread: &Entity<AcpThread>,
1838 event: &AcpThreadEvent,
1839 window: &mut Window,
1840 cx: &mut Context<Self>,
1841 ) {
1842 match event {
1843 AcpThreadEvent::NewEntry => {
1844 let len = thread.read(cx).entries().len();
1845 let index = len - 1;
1846 self.entry_view_state.update(cx, |view_state, cx| {
1847 view_state.sync_entry(index, thread, window, cx);
1848 self.list_state.splice_focusable(
1849 index..index,
1850 [view_state
1851 .entry(index)
1852 .and_then(|entry| entry.focus_handle(cx))],
1853 );
1854 });
1855 }
1856 AcpThreadEvent::EntryUpdated(index) => {
1857 self.entry_view_state.update(cx, |view_state, cx| {
1858 view_state.sync_entry(*index, thread, window, cx)
1859 });
1860 }
1861 AcpThreadEvent::EntriesRemoved(range) => {
1862 self.entry_view_state
1863 .update(cx, |view_state, _cx| view_state.remove(range.clone()));
1864 self.list_state.splice(range.clone(), 0);
1865 }
1866 AcpThreadEvent::ToolAuthorizationRequired => {
1867 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1868 }
1869 AcpThreadEvent::Retry(retry) => {
1870 self.thread_retry_status = Some(retry.clone());
1871 }
1872 AcpThreadEvent::Stopped => {
1873 self.thread_retry_status.take();
1874 let used_tools = thread.read(cx).used_tools_since_last_user_message();
1875 self.notify_with_sound(
1876 if used_tools {
1877 "Finished running tools"
1878 } else {
1879 "New message"
1880 },
1881 IconName::ZedAssistant,
1882 window,
1883 cx,
1884 );
1885
1886 if self.skip_queue_processing_count > 0 {
1887 self.skip_queue_processing_count -= 1;
1888 } else if self.user_interrupted_generation {
1889 // Manual interruption: don't auto-process queue.
1890 // Reset the flag so future completions can process normally.
1891 self.user_interrupted_generation = false;
1892 } else if !self.message_queue.is_empty() {
1893 self.send_queued_message_at_index(0, false, window, cx);
1894 }
1895
1896 self.history.update(cx, |history, cx| history.refresh(cx));
1897 }
1898 AcpThreadEvent::Refusal => {
1899 self.thread_retry_status.take();
1900 self.thread_error = Some(ThreadError::Refusal);
1901 let model_or_agent_name = self.current_model_name(cx);
1902 let notification_message =
1903 format!("{} refused to respond to this request", model_or_agent_name);
1904 self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
1905 }
1906 AcpThreadEvent::Error => {
1907 self.thread_retry_status.take();
1908 self.notify_with_sound(
1909 "Agent stopped due to an error",
1910 IconName::Warning,
1911 window,
1912 cx,
1913 );
1914 }
1915 AcpThreadEvent::LoadError(error) => {
1916 self.thread_retry_status.take();
1917 self.thread_state = ThreadState::LoadError(error.clone());
1918 if self.message_editor.focus_handle(cx).is_focused(window) {
1919 self.focus_handle.focus(window, cx)
1920 }
1921 }
1922 AcpThreadEvent::TitleUpdated => {
1923 let title = thread.read(cx).title();
1924 if let Some(title_editor) = self.title_editor() {
1925 title_editor.update(cx, |editor, cx| {
1926 if editor.text(cx) != title {
1927 editor.set_text(title, window, cx);
1928 }
1929 });
1930 }
1931 self.history.update(cx, |history, cx| history.refresh(cx));
1932 }
1933 AcpThreadEvent::PromptCapabilitiesUpdated => {
1934 self.prompt_capabilities
1935 .replace(thread.read(cx).prompt_capabilities());
1936 }
1937 AcpThreadEvent::TokenUsageUpdated => {
1938 self.update_turn_tokens(cx);
1939 }
1940 AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
1941 let mut available_commands = available_commands.clone();
1942
1943 if thread
1944 .read(cx)
1945 .connection()
1946 .auth_methods()
1947 .iter()
1948 .any(|method| method.id.0.as_ref() == "claude-login")
1949 {
1950 available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
1951 available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
1952 }
1953
1954 let has_commands = !available_commands.is_empty();
1955 self.available_commands.replace(available_commands);
1956
1957 let agent_display_name = self
1958 .agent_server_store
1959 .read(cx)
1960 .agent_display_name(&ExternalAgentServerName(self.agent.name()))
1961 .unwrap_or_else(|| self.agent.name());
1962
1963 let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
1964
1965 self.message_editor.update(cx, |editor, cx| {
1966 editor.set_placeholder_text(&new_placeholder, window, cx);
1967 });
1968 }
1969 AcpThreadEvent::ModeUpdated(_mode) => {
1970 // The connection keeps track of the mode
1971 cx.notify();
1972 }
1973 AcpThreadEvent::ConfigOptionsUpdated(_) => {
1974 // The watch task in ConfigOptionsView handles rebuilding selectors
1975 cx.notify();
1976 }
1977 }
1978 cx.notify();
1979 }
1980
1981 fn authenticate(
1982 &mut self,
1983 method: acp::AuthMethodId,
1984 window: &mut Window,
1985 cx: &mut Context<Self>,
1986 ) {
1987 let ThreadState::Unauthenticated {
1988 connection,
1989 pending_auth_method,
1990 configuration_view,
1991 ..
1992 } = &mut self.thread_state
1993 else {
1994 return;
1995 };
1996 let agent_telemetry_id = connection.telemetry_id();
1997
1998 // Check for the experimental "terminal-auth" _meta field
1999 let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
2000
2001 if let Some(auth_method) = auth_method {
2002 if let Some(meta) = &auth_method.meta {
2003 if let Some(terminal_auth) = meta.get("terminal-auth") {
2004 // Extract terminal auth details from meta
2005 if let (Some(command), Some(label)) = (
2006 terminal_auth.get("command").and_then(|v| v.as_str()),
2007 terminal_auth.get("label").and_then(|v| v.as_str()),
2008 ) {
2009 let args = terminal_auth
2010 .get("args")
2011 .and_then(|v| v.as_array())
2012 .map(|arr| {
2013 arr.iter()
2014 .filter_map(|v| v.as_str().map(String::from))
2015 .collect()
2016 })
2017 .unwrap_or_default();
2018
2019 let env = terminal_auth
2020 .get("env")
2021 .and_then(|v| v.as_object())
2022 .map(|obj| {
2023 obj.iter()
2024 .filter_map(|(k, v)| {
2025 v.as_str().map(|val| (k.clone(), val.to_string()))
2026 })
2027 .collect::<HashMap<String, String>>()
2028 })
2029 .unwrap_or_default();
2030
2031 // Run SpawnInTerminal in the same dir as the ACP server
2032 let cwd = connection
2033 .clone()
2034 .downcast::<agent_servers::AcpConnection>()
2035 .map(|acp_conn| acp_conn.root_dir().to_path_buf());
2036
2037 // Build SpawnInTerminal from _meta
2038 let login = task::SpawnInTerminal {
2039 id: task::TaskId(format!("external-agent-{}-login", label)),
2040 full_label: label.to_string(),
2041 label: label.to_string(),
2042 command: Some(command.to_string()),
2043 args,
2044 command_label: label.to_string(),
2045 cwd,
2046 env,
2047 use_new_terminal: true,
2048 allow_concurrent_runs: true,
2049 hide: task::HideStrategy::Always,
2050 ..Default::default()
2051 };
2052
2053 self.thread_error.take();
2054 configuration_view.take();
2055 pending_auth_method.replace(method.clone());
2056
2057 if let Some(workspace) = self.workspace.upgrade() {
2058 let project = self.project.clone();
2059 let authenticate = Self::spawn_external_agent_login(
2060 login, workspace, project, false, true, window, cx,
2061 );
2062 cx.notify();
2063 self.auth_task = Some(cx.spawn_in(window, {
2064 async move |this, cx| {
2065 let result = authenticate.await;
2066
2067 match &result {
2068 Ok(_) => telemetry::event!(
2069 "Authenticate Agent Succeeded",
2070 agent = agent_telemetry_id
2071 ),
2072 Err(_) => {
2073 telemetry::event!(
2074 "Authenticate Agent Failed",
2075 agent = agent_telemetry_id,
2076 )
2077 }
2078 }
2079
2080 this.update_in(cx, |this, window, cx| {
2081 if let Err(err) = result {
2082 if let ThreadState::Unauthenticated {
2083 pending_auth_method,
2084 ..
2085 } = &mut this.thread_state
2086 {
2087 pending_auth_method.take();
2088 }
2089 this.handle_thread_error(err, cx);
2090 } else {
2091 this.reset(window, cx);
2092 }
2093 this.auth_task.take()
2094 })
2095 .ok();
2096 }
2097 }));
2098 }
2099 return;
2100 }
2101 }
2102 }
2103 }
2104
2105 if method.0.as_ref() == "gemini-api-key" {
2106 let registry = LanguageModelRegistry::global(cx);
2107 let provider = registry
2108 .read(cx)
2109 .provider(&language_model::GOOGLE_PROVIDER_ID)
2110 .unwrap();
2111 if !provider.is_authenticated(cx) {
2112 let this = cx.weak_entity();
2113 let agent = self.agent.clone();
2114 let connection = connection.clone();
2115 window.defer(cx, |window, cx| {
2116 Self::handle_auth_required(
2117 this,
2118 AuthRequired {
2119 description: Some("GEMINI_API_KEY must be set".to_owned()),
2120 provider_id: Some(language_model::GOOGLE_PROVIDER_ID),
2121 },
2122 agent,
2123 connection,
2124 window,
2125 cx,
2126 );
2127 });
2128 return;
2129 }
2130 } else if method.0.as_ref() == "vertex-ai"
2131 && std::env::var("GOOGLE_API_KEY").is_err()
2132 && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
2133 || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()))
2134 {
2135 let this = cx.weak_entity();
2136 let agent = self.agent.clone();
2137 let connection = connection.clone();
2138
2139 window.defer(cx, |window, cx| {
2140 Self::handle_auth_required(
2141 this,
2142 AuthRequired {
2143 description: Some(
2144 "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed."
2145 .to_owned(),
2146 ),
2147 provider_id: None,
2148 },
2149 agent,
2150 connection,
2151 window,
2152 cx,
2153 )
2154 });
2155 return;
2156 }
2157
2158 self.thread_error.take();
2159 configuration_view.take();
2160 pending_auth_method.replace(method.clone());
2161 let authenticate = if (method.0.as_ref() == "claude-login"
2162 || method.0.as_ref() == "spawn-gemini-cli")
2163 && let Some(login) = self.login.clone()
2164 {
2165 if let Some(workspace) = self.workspace.upgrade() {
2166 let project = self.project.clone();
2167 Self::spawn_external_agent_login(
2168 login, workspace, project, false, false, window, cx,
2169 )
2170 } else {
2171 Task::ready(Ok(()))
2172 }
2173 } else {
2174 connection.authenticate(method, cx)
2175 };
2176 cx.notify();
2177 self.auth_task = Some(cx.spawn_in(window, {
2178 async move |this, cx| {
2179 let result = authenticate.await;
2180
2181 match &result {
2182 Ok(_) => telemetry::event!(
2183 "Authenticate Agent Succeeded",
2184 agent = agent_telemetry_id
2185 ),
2186 Err(_) => {
2187 telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
2188 }
2189 }
2190
2191 this.update_in(cx, |this, window, cx| {
2192 if let Err(err) = result {
2193 if let ThreadState::Unauthenticated {
2194 pending_auth_method,
2195 ..
2196 } = &mut this.thread_state
2197 {
2198 pending_auth_method.take();
2199 }
2200 this.handle_thread_error(err, cx);
2201 } else {
2202 this.reset(window, cx);
2203 }
2204 this.auth_task.take()
2205 })
2206 .ok();
2207 }
2208 }));
2209 }
2210
2211 fn spawn_external_agent_login(
2212 login: task::SpawnInTerminal,
2213 workspace: Entity<Workspace>,
2214 project: Entity<Project>,
2215 previous_attempt: bool,
2216 check_exit_code: bool,
2217 window: &mut Window,
2218 cx: &mut App,
2219 ) -> Task<Result<()>> {
2220 let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
2221 return Task::ready(Ok(()));
2222 };
2223
2224 window.spawn(cx, async move |cx| {
2225 let mut task = login.clone();
2226 if let Some(cmd) = &task.command {
2227 // Have "node" command use Zed's managed Node runtime by default
2228 if cmd == "node" {
2229 let resolved_node_runtime = project
2230 .update(cx, |project, cx| {
2231 let agent_server_store = project.agent_server_store().clone();
2232 agent_server_store.update(cx, |store, cx| {
2233 store.node_runtime().map(|node_runtime| {
2234 cx.background_spawn(async move {
2235 node_runtime.binary_path().await
2236 })
2237 })
2238 })
2239 });
2240
2241 if let Some(resolve_task) = resolved_node_runtime {
2242 if let Ok(node_path) = resolve_task.await {
2243 task.command = Some(node_path.to_string_lossy().to_string());
2244 }
2245 }
2246 }
2247 }
2248 task.shell = task::Shell::WithArguments {
2249 program: task.command.take().expect("login command should be set"),
2250 args: std::mem::take(&mut task.args),
2251 title_override: None
2252 };
2253 task.full_label = task.label.clone();
2254 task.id = task::TaskId(format!("external-agent-{}-login", task.label));
2255 task.command_label = task.label.clone();
2256 task.use_new_terminal = true;
2257 task.allow_concurrent_runs = true;
2258 task.hide = task::HideStrategy::Always;
2259
2260 let terminal = terminal_panel
2261 .update_in(cx, |terminal_panel, window, cx| {
2262 terminal_panel.spawn_task(&task, window, cx)
2263 })?
2264 .await?;
2265
2266 if check_exit_code {
2267 // For extension-based auth, wait for the process to exit and check exit code
2268 let exit_status = terminal
2269 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
2270 .await;
2271
2272 match exit_status {
2273 Some(status) if status.success() => {
2274 Ok(())
2275 }
2276 Some(status) => {
2277 Err(anyhow!("Login command failed with exit code: {:?}", status.code()))
2278 }
2279 None => {
2280 Err(anyhow!("Login command terminated without exit status"))
2281 }
2282 }
2283 } else {
2284 // For hardcoded agents (claude-login, gemini-cli): look for specific output
2285 let mut exit_status = terminal
2286 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
2287 .fuse();
2288
2289 let logged_in = cx
2290 .spawn({
2291 let terminal = terminal.clone();
2292 async move |cx| {
2293 loop {
2294 cx.background_executor().timer(Duration::from_secs(1)).await;
2295 let content =
2296 terminal.update(cx, |terminal, _cx| terminal.get_content())?;
2297 if content.contains("Login successful")
2298 || content.contains("Type your message")
2299 {
2300 return anyhow::Ok(());
2301 }
2302 }
2303 }
2304 })
2305 .fuse();
2306 futures::pin_mut!(logged_in);
2307 futures::select_biased! {
2308 result = logged_in => {
2309 if let Err(e) = result {
2310 log::error!("{e}");
2311 return Err(anyhow!("exited before logging in"));
2312 }
2313 }
2314 _ = exit_status => {
2315 if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server()) && login.label.contains("gemini") {
2316 return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), true, false, window, cx))?.await
2317 }
2318 return Err(anyhow!("exited before logging in"));
2319 }
2320 }
2321 terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
2322 Ok(())
2323 }
2324 })
2325 }
2326
2327 pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
2328 self.thread().is_some_and(|thread| {
2329 thread.read(cx).entries().iter().any(|entry| {
2330 matches!(
2331 entry,
2332 AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some()
2333 )
2334 })
2335 })
2336 }
2337
2338 fn authorize_tool_call(
2339 &mut self,
2340 tool_call_id: acp::ToolCallId,
2341 option_id: acp::PermissionOptionId,
2342 option_kind: acp::PermissionOptionKind,
2343 window: &mut Window,
2344 cx: &mut Context<Self>,
2345 ) {
2346 let Some(thread) = self.thread() else {
2347 return;
2348 };
2349 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
2350
2351 telemetry::event!(
2352 "Agent Tool Call Authorized",
2353 agent = agent_telemetry_id,
2354 session = thread.read(cx).session_id(),
2355 option = option_kind
2356 );
2357
2358 thread.update(cx, |thread, cx| {
2359 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
2360 });
2361 if self.should_be_following {
2362 self.workspace
2363 .update(cx, |workspace, cx| {
2364 workspace.follow(CollaboratorId::Agent, window, cx);
2365 })
2366 .ok();
2367 }
2368 cx.notify();
2369 }
2370
2371 fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
2372 let Some(thread) = self.thread() else {
2373 return;
2374 };
2375
2376 thread
2377 .update(cx, |thread, cx| {
2378 thread.restore_checkpoint(message_id.clone(), cx)
2379 })
2380 .detach_and_log_err(cx);
2381 }
2382
2383 fn render_entry(
2384 &self,
2385 entry_ix: usize,
2386 total_entries: usize,
2387 entry: &AgentThreadEntry,
2388 window: &mut Window,
2389 cx: &Context<Self>,
2390 ) -> AnyElement {
2391 let is_indented = entry.is_indented();
2392 let is_first_indented = is_indented
2393 && self.thread().is_some_and(|thread| {
2394 thread
2395 .read(cx)
2396 .entries()
2397 .get(entry_ix.saturating_sub(1))
2398 .is_none_or(|entry| !entry.is_indented())
2399 });
2400
2401 let primary = match &entry {
2402 AgentThreadEntry::UserMessage(message) => {
2403 let Some(editor) = self
2404 .entry_view_state
2405 .read(cx)
2406 .entry(entry_ix)
2407 .and_then(|entry| entry.message_editor())
2408 .cloned()
2409 else {
2410 return Empty.into_any_element();
2411 };
2412
2413 let editing = self.editing_message == Some(entry_ix);
2414 let editor_focus = editor.focus_handle(cx).is_focused(window);
2415 let focus_border = cx.theme().colors().border_focused;
2416
2417 let rules_item = if entry_ix == 0 {
2418 self.render_rules_item(cx)
2419 } else {
2420 None
2421 };
2422
2423 let has_checkpoint_button = message
2424 .checkpoint
2425 .as_ref()
2426 .is_some_and(|checkpoint| checkpoint.show);
2427
2428 let agent_name = self.agent.name();
2429
2430 v_flex()
2431 .id(("user_message", entry_ix))
2432 .map(|this| {
2433 if is_first_indented {
2434 this.pt_0p5()
2435 } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
2436 this.pt(rems_from_px(18.))
2437 } else if rules_item.is_some() {
2438 this.pt_3()
2439 } else {
2440 this.pt_2()
2441 }
2442 })
2443 .pb_3()
2444 .px_2()
2445 .gap_1p5()
2446 .w_full()
2447 .children(rules_item)
2448 .children(message.id.clone().and_then(|message_id| {
2449 message.checkpoint.as_ref()?.show.then(|| {
2450 h_flex()
2451 .px_3()
2452 .gap_2()
2453 .child(Divider::horizontal())
2454 .child(
2455 Button::new("restore-checkpoint", "Restore Checkpoint")
2456 .icon(IconName::Undo)
2457 .icon_size(IconSize::XSmall)
2458 .icon_position(IconPosition::Start)
2459 .label_size(LabelSize::XSmall)
2460 .icon_color(Color::Muted)
2461 .color(Color::Muted)
2462 .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
2463 .on_click(cx.listener(move |this, _, _window, cx| {
2464 this.restore_checkpoint(&message_id, cx);
2465 }))
2466 )
2467 .child(Divider::horizontal())
2468 })
2469 }))
2470 .child(
2471 div()
2472 .relative()
2473 .child(
2474 div()
2475 .py_3()
2476 .px_2()
2477 .rounded_md()
2478 .shadow_md()
2479 .bg(cx.theme().colors().editor_background)
2480 .border_1()
2481 .when(is_indented, |this| {
2482 this.py_2().px_2().shadow_sm()
2483 })
2484 .when(editing && !editor_focus, |this| this.border_dashed())
2485 .border_color(cx.theme().colors().border)
2486 .map(|this|{
2487 if editing && editor_focus {
2488 this.border_color(focus_border)
2489 } else if message.id.is_some() {
2490 this.hover(|s| s.border_color(focus_border.opacity(0.8)))
2491 } else {
2492 this
2493 }
2494 })
2495 .text_xs()
2496 .child(editor.clone().into_any_element())
2497 )
2498 .when(editor_focus, |this| {
2499 let base_container = h_flex()
2500 .absolute()
2501 .top_neg_3p5()
2502 .right_3()
2503 .gap_1()
2504 .rounded_sm()
2505 .border_1()
2506 .border_color(cx.theme().colors().border)
2507 .bg(cx.theme().colors().editor_background)
2508 .overflow_hidden();
2509
2510 if message.id.is_some() {
2511 this.child(
2512 base_container
2513 .child(
2514 IconButton::new("cancel", IconName::Close)
2515 .disabled(self.is_loading_contents)
2516 .icon_color(Color::Error)
2517 .icon_size(IconSize::XSmall)
2518 .on_click(cx.listener(Self::cancel_editing))
2519 )
2520 .child(
2521 if self.is_loading_contents {
2522 div()
2523 .id("loading-edited-message-content")
2524 .tooltip(Tooltip::text("Loading Added Context…"))
2525 .child(loading_contents_spinner(IconSize::XSmall))
2526 .into_any_element()
2527 } else {
2528 IconButton::new("regenerate", IconName::Return)
2529 .icon_color(Color::Muted)
2530 .icon_size(IconSize::XSmall)
2531 .tooltip(Tooltip::text(
2532 "Editing will restart the thread from this point."
2533 ))
2534 .on_click(cx.listener({
2535 let editor = editor.clone();
2536 move |this, _, window, cx| {
2537 this.regenerate(
2538 entry_ix, editor.clone(), window, cx,
2539 );
2540 }
2541 })).into_any_element()
2542 }
2543 )
2544 )
2545 } else {
2546 this.child(
2547 base_container
2548 .border_dashed()
2549 .child(
2550 IconButton::new("editing_unavailable", IconName::PencilUnavailable)
2551 .icon_size(IconSize::Small)
2552 .icon_color(Color::Muted)
2553 .style(ButtonStyle::Transparent)
2554 .tooltip(Tooltip::element({
2555 move |_, _| {
2556 v_flex()
2557 .gap_1()
2558 .child(Label::new("Unavailable Editing")).child(
2559 div().max_w_64().child(
2560 Label::new(format!(
2561 "Editing previous messages is not available for {} yet.",
2562 agent_name.clone()
2563 ))
2564 .size(LabelSize::Small)
2565 .color(Color::Muted),
2566 ),
2567 )
2568 .into_any_element()
2569 }
2570 }))
2571 )
2572 )
2573 }
2574 }),
2575 )
2576 .into_any()
2577 }
2578 AgentThreadEntry::AssistantMessage(AssistantMessage {
2579 chunks,
2580 indented: _,
2581 }) => {
2582 let mut is_blank = true;
2583 let is_last = entry_ix + 1 == total_entries;
2584
2585 let style = default_markdown_style(false, false, window, cx);
2586 let message_body = v_flex()
2587 .w_full()
2588 .gap_3()
2589 .children(chunks.iter().enumerate().filter_map(
2590 |(chunk_ix, chunk)| match chunk {
2591 AssistantMessageChunk::Message { block } => {
2592 block.markdown().and_then(|md| {
2593 let this_is_blank = md.read(cx).source().trim().is_empty();
2594 is_blank = is_blank && this_is_blank;
2595 if this_is_blank {
2596 return None;
2597 }
2598
2599 Some(
2600 self.render_markdown(md.clone(), style.clone())
2601 .into_any_element(),
2602 )
2603 })
2604 }
2605 AssistantMessageChunk::Thought { block } => {
2606 block.markdown().and_then(|md| {
2607 let this_is_blank = md.read(cx).source().trim().is_empty();
2608 is_blank = is_blank && this_is_blank;
2609 if this_is_blank {
2610 return None;
2611 }
2612 Some(
2613 self.render_thinking_block(
2614 entry_ix,
2615 chunk_ix,
2616 md.clone(),
2617 window,
2618 cx,
2619 )
2620 .into_any_element(),
2621 )
2622 })
2623 }
2624 },
2625 ))
2626 .into_any();
2627
2628 if is_blank {
2629 Empty.into_any()
2630 } else {
2631 v_flex()
2632 .px_5()
2633 .py_1p5()
2634 .when(is_last, |this| this.pb_4())
2635 .w_full()
2636 .text_ui(cx)
2637 .child(self.render_message_context_menu(entry_ix, message_body, cx))
2638 .into_any()
2639 }
2640 }
2641 AgentThreadEntry::ToolCall(tool_call) => {
2642 let has_terminals = tool_call.terminals().next().is_some();
2643
2644 div()
2645 .w_full()
2646 .map(|this| {
2647 if has_terminals {
2648 this.children(tool_call.terminals().map(|terminal| {
2649 self.render_terminal_tool_call(
2650 entry_ix, terminal, tool_call, window, cx,
2651 )
2652 }))
2653 } else {
2654 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
2655 }
2656 })
2657 .into_any()
2658 }
2659 };
2660
2661 let primary = if is_indented {
2662 let line_top = if is_first_indented {
2663 rems_from_px(-12.0)
2664 } else {
2665 rems_from_px(0.0)
2666 };
2667
2668 div()
2669 .relative()
2670 .w_full()
2671 .pl_5()
2672 .bg(cx.theme().colors().panel_background.opacity(0.2))
2673 .child(
2674 div()
2675 .absolute()
2676 .left(rems_from_px(18.0))
2677 .top(line_top)
2678 .bottom_0()
2679 .w_px()
2680 .bg(cx.theme().colors().border.opacity(0.6)),
2681 )
2682 .child(primary)
2683 .into_any_element()
2684 } else {
2685 primary
2686 };
2687
2688 let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
2689 matches!(
2690 tool_call.status,
2691 ToolCallStatus::WaitingForConfirmation { .. }
2692 )
2693 } else {
2694 false
2695 };
2696
2697 let Some(thread) = self.thread() else {
2698 return primary;
2699 };
2700
2701 let primary = if entry_ix == total_entries - 1 {
2702 v_flex()
2703 .w_full()
2704 .child(primary)
2705 .map(|this| {
2706 if needs_confirmation {
2707 this.child(self.render_generating(true, cx))
2708 } else {
2709 this.child(self.render_thread_controls(&thread, cx))
2710 }
2711 })
2712 .when_some(
2713 self.thread_feedback.comments_editor.clone(),
2714 |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
2715 )
2716 .into_any_element()
2717 } else {
2718 primary
2719 };
2720
2721 if let Some(editing_index) = self.editing_message.as_ref()
2722 && *editing_index < entry_ix
2723 {
2724 let backdrop = div()
2725 .id(("backdrop", entry_ix))
2726 .size_full()
2727 .absolute()
2728 .inset_0()
2729 .bg(cx.theme().colors().panel_background)
2730 .opacity(0.8)
2731 .block_mouse_except_scroll()
2732 .on_click(cx.listener(Self::cancel_editing));
2733
2734 div()
2735 .relative()
2736 .child(primary)
2737 .child(backdrop)
2738 .into_any_element()
2739 } else {
2740 primary
2741 }
2742 }
2743
2744 fn render_message_context_menu(
2745 &self,
2746 entry_ix: usize,
2747 message_body: AnyElement,
2748 cx: &Context<Self>,
2749 ) -> AnyElement {
2750 let entity = cx.entity();
2751 let workspace = self.workspace.clone();
2752
2753 right_click_menu(format!("agent_context_menu-{}", entry_ix))
2754 .trigger(move |_, _, _| message_body)
2755 .menu(move |window, cx| {
2756 let focus = window.focused(cx);
2757 let entity = entity.clone();
2758 let workspace = workspace.clone();
2759
2760 ContextMenu::build(window, cx, move |menu, _, cx| {
2761 let is_at_top = entity.read(cx).list_state.logical_scroll_top().item_ix == 0;
2762
2763 let copy_this_agent_response =
2764 ContextMenuEntry::new("Copy This Agent Response").handler({
2765 let entity = entity.clone();
2766 move |_, cx| {
2767 entity.update(cx, |this, cx| {
2768 if let Some(thread) = this.thread() {
2769 let entries = thread.read(cx).entries();
2770 if let Some(text) =
2771 Self::get_agent_message_content(entries, entry_ix, cx)
2772 {
2773 cx.write_to_clipboard(ClipboardItem::new_string(text));
2774 }
2775 }
2776 });
2777 }
2778 });
2779
2780 let scroll_item = if is_at_top {
2781 ContextMenuEntry::new("Scroll to Bottom").handler({
2782 let entity = entity.clone();
2783 move |_, cx| {
2784 entity.update(cx, |this, cx| {
2785 this.scroll_to_bottom(cx);
2786 });
2787 }
2788 })
2789 } else {
2790 ContextMenuEntry::new("Scroll to Top").handler({
2791 let entity = entity.clone();
2792 move |_, cx| {
2793 entity.update(cx, |this, cx| {
2794 this.scroll_to_top(cx);
2795 });
2796 }
2797 })
2798 };
2799
2800 let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
2801 .handler({
2802 let entity = entity.clone();
2803 let workspace = workspace.clone();
2804 move |window, cx| {
2805 if let Some(workspace) = workspace.upgrade() {
2806 entity
2807 .update(cx, |this, cx| {
2808 this.open_thread_as_markdown(workspace, window, cx)
2809 })
2810 .detach_and_log_err(cx);
2811 }
2812 }
2813 });
2814
2815 menu.when_some(focus, |menu, focus| menu.context(focus))
2816 .action("Copy Selection", Box::new(markdown::CopyAsMarkdown))
2817 .item(copy_this_agent_response)
2818 .separator()
2819 .item(scroll_item)
2820 .item(open_thread_as_markdown)
2821 })
2822 })
2823 .into_any_element()
2824 }
2825
2826 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
2827 cx.theme()
2828 .colors()
2829 .element_background
2830 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
2831 }
2832
2833 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
2834 cx.theme().colors().border.opacity(0.8)
2835 }
2836
2837 fn tool_name_font_size(&self) -> Rems {
2838 rems_from_px(13.)
2839 }
2840
2841 fn render_thinking_block(
2842 &self,
2843 entry_ix: usize,
2844 chunk_ix: usize,
2845 chunk: Entity<Markdown>,
2846 window: &Window,
2847 cx: &Context<Self>,
2848 ) -> AnyElement {
2849 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
2850 let card_header_id = SharedString::from("inner-card-header");
2851
2852 let key = (entry_ix, chunk_ix);
2853
2854 let is_open = self.expanded_thinking_blocks.contains(&key);
2855
2856 let scroll_handle = self
2857 .entry_view_state
2858 .read(cx)
2859 .entry(entry_ix)
2860 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
2861
2862 let thinking_content = {
2863 div()
2864 .id(("thinking-content", chunk_ix))
2865 .when_some(scroll_handle, |this, scroll_handle| {
2866 this.track_scroll(&scroll_handle)
2867 })
2868 .text_ui_sm(cx)
2869 .overflow_hidden()
2870 .child(
2871 self.render_markdown(chunk, default_markdown_style(false, false, window, cx)),
2872 )
2873 };
2874
2875 v_flex()
2876 .gap_1()
2877 .child(
2878 h_flex()
2879 .id(header_id)
2880 .group(&card_header_id)
2881 .relative()
2882 .w_full()
2883 .pr_1()
2884 .justify_between()
2885 .child(
2886 h_flex()
2887 .h(window.line_height() - px(2.))
2888 .gap_1p5()
2889 .overflow_hidden()
2890 .child(
2891 Icon::new(IconName::ToolThink)
2892 .size(IconSize::Small)
2893 .color(Color::Muted),
2894 )
2895 .child(
2896 div()
2897 .text_size(self.tool_name_font_size())
2898 .text_color(cx.theme().colors().text_muted)
2899 .child("Thinking"),
2900 ),
2901 )
2902 .child(
2903 Disclosure::new(("expand", entry_ix), is_open)
2904 .opened_icon(IconName::ChevronUp)
2905 .closed_icon(IconName::ChevronDown)
2906 .visible_on_hover(&card_header_id)
2907 .on_click(cx.listener({
2908 move |this, _event, _window, cx| {
2909 if is_open {
2910 this.expanded_thinking_blocks.remove(&key);
2911 } else {
2912 this.expanded_thinking_blocks.insert(key);
2913 }
2914 cx.notify();
2915 }
2916 })),
2917 )
2918 .on_click(cx.listener({
2919 move |this, _event, _window, cx| {
2920 if is_open {
2921 this.expanded_thinking_blocks.remove(&key);
2922 } else {
2923 this.expanded_thinking_blocks.insert(key);
2924 }
2925 cx.notify();
2926 }
2927 })),
2928 )
2929 .when(is_open, |this| {
2930 this.child(
2931 div()
2932 .ml_1p5()
2933 .pl_3p5()
2934 .border_l_1()
2935 .border_color(self.tool_card_border_color(cx))
2936 .child(thinking_content),
2937 )
2938 })
2939 .into_any_element()
2940 }
2941
2942 fn render_tool_call(
2943 &self,
2944 entry_ix: usize,
2945 tool_call: &ToolCall,
2946 window: &Window,
2947 cx: &Context<Self>,
2948 ) -> Div {
2949 let has_location = tool_call.locations.len() == 1;
2950 let card_header_id = SharedString::from("inner-tool-call-header");
2951
2952 let failed_or_canceled = match &tool_call.status {
2953 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
2954 _ => false,
2955 };
2956
2957 let needs_confirmation = matches!(
2958 tool_call.status,
2959 ToolCallStatus::WaitingForConfirmation { .. }
2960 );
2961 let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
2962
2963 let is_edit =
2964 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
2965 let is_subagent = tool_call.is_subagent();
2966
2967 // For subagent tool calls, render the subagent cards directly without wrapper
2968 if is_subagent {
2969 return self.render_subagent_tool_call(entry_ix, tool_call, window, cx);
2970 }
2971
2972 let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
2973 let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| {
2974 self.entry_view_state
2975 .read(cx)
2976 .entry(entry_ix)
2977 .and_then(|entry| entry.editor_for_diff(diff))
2978 .is_some()
2979 && diff.read(cx).has_revealed_range(cx)
2980 });
2981
2982 let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
2983
2984 let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
2985 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
2986 let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
2987
2988 let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
2989
2990 let input_output_header = |label: SharedString| {
2991 Label::new(label)
2992 .size(LabelSize::XSmall)
2993 .color(Color::Muted)
2994 .buffer_font(cx)
2995 };
2996
2997 let tool_output_display = if is_open {
2998 match &tool_call.status {
2999 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
3000 .w_full()
3001 .children(
3002 tool_call
3003 .content
3004 .iter()
3005 .enumerate()
3006 .map(|(content_ix, content)| {
3007 div()
3008 .child(self.render_tool_call_content(
3009 entry_ix,
3010 content,
3011 content_ix,
3012 tool_call,
3013 use_card_layout,
3014 has_image_content,
3015 failed_or_canceled,
3016 window,
3017 cx,
3018 ))
3019 .into_any_element()
3020 }),
3021 )
3022 .when(should_show_raw_input, |this| {
3023 let is_raw_input_expanded =
3024 self.expanded_tool_call_raw_inputs.contains(&tool_call.id);
3025
3026 let input_header = if is_raw_input_expanded {
3027 "Raw Input:"
3028 } else {
3029 "View Raw Input"
3030 };
3031
3032 this.child(
3033 v_flex()
3034 .p_2()
3035 .gap_1()
3036 .border_t_1()
3037 .border_color(self.tool_card_border_color(cx))
3038 .child(
3039 h_flex()
3040 .id("disclosure_container")
3041 .pl_0p5()
3042 .gap_1()
3043 .justify_between()
3044 .rounded_xs()
3045 .hover(|s| s.bg(cx.theme().colors().element_hover))
3046 .child(input_output_header(input_header.into()))
3047 .child(
3048 Disclosure::new(
3049 ("raw-input-disclosure", entry_ix),
3050 is_raw_input_expanded,
3051 )
3052 .opened_icon(IconName::ChevronUp)
3053 .closed_icon(IconName::ChevronDown),
3054 )
3055 .on_click(cx.listener({
3056 let id = tool_call.id.clone();
3057
3058 move |this: &mut Self, _, _, cx| {
3059 if this.expanded_tool_call_raw_inputs.contains(&id)
3060 {
3061 this.expanded_tool_call_raw_inputs.remove(&id);
3062 } else {
3063 this.expanded_tool_call_raw_inputs
3064 .insert(id.clone());
3065 }
3066 cx.notify();
3067 }
3068 })),
3069 )
3070 .when(is_raw_input_expanded, |this| {
3071 this.children(tool_call.raw_input_markdown.clone().map(
3072 |input| {
3073 self.render_markdown(
3074 input,
3075 default_markdown_style(false, false, window, cx),
3076 )
3077 },
3078 ))
3079 }),
3080 )
3081 })
3082 .child(self.render_permission_buttons(
3083 tool_call.kind,
3084 options,
3085 entry_ix,
3086 tool_call.id.clone(),
3087 cx,
3088 ))
3089 .into_any(),
3090 ToolCallStatus::Pending | ToolCallStatus::InProgress
3091 if is_edit
3092 && tool_call.content.is_empty()
3093 && self.as_native_connection(cx).is_some() =>
3094 {
3095 self.render_diff_loading(cx).into_any()
3096 }
3097 ToolCallStatus::Pending
3098 | ToolCallStatus::InProgress
3099 | ToolCallStatus::Completed
3100 | ToolCallStatus::Failed
3101 | ToolCallStatus::Canceled => {
3102 v_flex()
3103 .when(should_show_raw_input, |this| {
3104 this.mt_1p5().w_full().child(
3105 v_flex()
3106 .ml(rems(0.4))
3107 .px_3p5()
3108 .pb_1()
3109 .gap_1()
3110 .border_l_1()
3111 .border_color(self.tool_card_border_color(cx))
3112 .child(input_output_header("Raw Input:".into()))
3113 .children(tool_call.raw_input_markdown.clone().map(|input| {
3114 div().id(("tool-call-raw-input-markdown", entry_ix)).child(
3115 self.render_markdown(
3116 input,
3117 default_markdown_style(false, false, window, cx),
3118 ),
3119 )
3120 }))
3121 .child(input_output_header("Output:".into())),
3122 )
3123 })
3124 .children(tool_call.content.iter().enumerate().map(
3125 |(content_ix, content)| {
3126 div().id(("tool-call-output", entry_ix)).child(
3127 self.render_tool_call_content(
3128 entry_ix,
3129 content,
3130 content_ix,
3131 tool_call,
3132 use_card_layout,
3133 has_image_content,
3134 failed_or_canceled,
3135 window,
3136 cx,
3137 ),
3138 )
3139 },
3140 ))
3141 .into_any()
3142 }
3143 ToolCallStatus::Rejected => Empty.into_any(),
3144 }
3145 .into()
3146 } else {
3147 None
3148 };
3149
3150 v_flex()
3151 .map(|this| {
3152 if use_card_layout {
3153 this.my_1p5()
3154 .rounded_md()
3155 .border_1()
3156 .when(failed_or_canceled, |this| this.border_dashed())
3157 .border_color(self.tool_card_border_color(cx))
3158 .bg(cx.theme().colors().editor_background)
3159 .overflow_hidden()
3160 } else {
3161 this.my_1()
3162 }
3163 })
3164 .map(|this| {
3165 if has_location && !use_card_layout {
3166 this.ml_4()
3167 } else {
3168 this.ml_5()
3169 }
3170 })
3171 .mr_5()
3172 .map(|this| {
3173 if is_terminal_tool {
3174 let label_source = tool_call.label.read(cx).source();
3175 this.child(self.render_collapsible_command(true, label_source, &tool_call.id, cx))
3176 } else {
3177 this.child(
3178 h_flex()
3179 .group(&card_header_id)
3180 .relative()
3181 .w_full()
3182 .gap_1()
3183 .justify_between()
3184 .when(use_card_layout, |this| {
3185 this.p_0p5()
3186 .rounded_t(rems_from_px(5.))
3187 .bg(self.tool_card_header_bg(cx))
3188 })
3189 .child(self.render_tool_call_label(
3190 entry_ix,
3191 tool_call,
3192 is_edit,
3193 is_cancelled_edit,
3194 has_revealed_diff,
3195 use_card_layout,
3196 window,
3197 cx,
3198 ))
3199 .when(is_collapsible || failed_or_canceled, |this| {
3200 let diff_for_discard =
3201 if has_revealed_diff && is_cancelled_edit && cx.has_flag::<AgentV2FeatureFlag>() {
3202 tool_call.diffs().next().cloned()
3203 } else {
3204 None
3205 };
3206 this.child(
3207 h_flex()
3208 .px_1()
3209 .when_some(diff_for_discard.clone(), |this, _| this.pr_0p5())
3210 .gap_1()
3211 .when(is_collapsible, |this| {
3212 this.child(
3213 Disclosure::new(("expand-output", entry_ix), is_open)
3214 .opened_icon(IconName::ChevronUp)
3215 .closed_icon(IconName::ChevronDown)
3216 .visible_on_hover(&card_header_id)
3217 .on_click(cx.listener({
3218 let id = tool_call.id.clone();
3219 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
3220 if is_open {
3221 this.expanded_tool_calls.remove(&id);
3222 } else {
3223 this.expanded_tool_calls.insert(id.clone());
3224 }
3225 cx.notify();
3226 }
3227 })),
3228 )
3229 })
3230 .when(failed_or_canceled, |this| {
3231 if is_cancelled_edit && !has_revealed_diff {
3232 this.child(
3233 div()
3234 .id(entry_ix)
3235 .tooltip(Tooltip::text(
3236 "Interrupted Edit",
3237 ))
3238 .child(
3239 Icon::new(IconName::XCircle)
3240 .color(Color::Muted)
3241 .size(IconSize::Small),
3242 ),
3243 )
3244 } else if is_cancelled_edit {
3245 this
3246 } else {
3247 this.child(
3248 Icon::new(IconName::Close)
3249 .color(Color::Error)
3250 .size(IconSize::Small),
3251 )
3252 }
3253 })
3254 .when_some(diff_for_discard, |this, diff| {
3255 let tool_call_id = tool_call.id.clone();
3256 let is_discarded = self.discarded_partial_edits.contains(&tool_call_id);
3257 this.when(!is_discarded, |this| {
3258 this.child(
3259 IconButton::new(
3260 ("discard-partial-edit", entry_ix),
3261 IconName::Undo,
3262 )
3263 .icon_size(IconSize::Small)
3264 .tooltip(move |_, cx| Tooltip::with_meta(
3265 "Discard Interrupted Edit",
3266 None,
3267 "You can discard this interrupted partial edit and restore the original file content.",
3268 cx
3269 ))
3270 .on_click(cx.listener({
3271 let tool_call_id = tool_call_id.clone();
3272 move |this, _, _window, cx| {
3273 let diff_data = diff.read(cx);
3274 let base_text = diff_data.base_text().clone();
3275 let buffer = diff_data.buffer().clone();
3276 buffer.update(cx, |buffer, cx| {
3277 buffer.set_text(base_text.as_ref(), cx);
3278 });
3279 this.discarded_partial_edits.insert(tool_call_id.clone());
3280 cx.notify();
3281 }
3282 })),
3283 )
3284 })
3285 })
3286
3287 )
3288 }),
3289 )
3290 }
3291 })
3292 .children(tool_output_display)
3293 }
3294
3295 fn render_tool_call_label(
3296 &self,
3297 entry_ix: usize,
3298 tool_call: &ToolCall,
3299 is_edit: bool,
3300 has_failed: bool,
3301 has_revealed_diff: bool,
3302 use_card_layout: bool,
3303 window: &Window,
3304 cx: &Context<Self>,
3305 ) -> Div {
3306 let has_location = tool_call.locations.len() == 1;
3307 let is_file = tool_call.kind == acp::ToolKind::Edit && has_location;
3308
3309 let file_icon = if has_location {
3310 FileIcons::get_icon(&tool_call.locations[0].path, cx)
3311 .map(Icon::from_path)
3312 .unwrap_or(Icon::new(IconName::ToolPencil))
3313 } else {
3314 Icon::new(IconName::ToolPencil)
3315 };
3316
3317 let tool_icon = if is_file && has_failed && has_revealed_diff {
3318 div()
3319 .id(entry_ix)
3320 .tooltip(Tooltip::text("Interrupted Edit"))
3321 .child(DecoratedIcon::new(
3322 file_icon,
3323 Some(
3324 IconDecoration::new(
3325 IconDecorationKind::Triangle,
3326 self.tool_card_header_bg(cx),
3327 cx,
3328 )
3329 .color(cx.theme().status().warning)
3330 .position(gpui::Point {
3331 x: px(-2.),
3332 y: px(-2.),
3333 }),
3334 ),
3335 ))
3336 .into_any_element()
3337 } else if is_file {
3338 div().child(file_icon).into_any_element()
3339 } else {
3340 div()
3341 .child(
3342 Icon::new(match tool_call.kind {
3343 acp::ToolKind::Read => IconName::ToolSearch,
3344 acp::ToolKind::Edit => IconName::ToolPencil,
3345 acp::ToolKind::Delete => IconName::ToolDeleteFile,
3346 acp::ToolKind::Move => IconName::ArrowRightLeft,
3347 acp::ToolKind::Search => IconName::ToolSearch,
3348 acp::ToolKind::Execute => IconName::ToolTerminal,
3349 acp::ToolKind::Think => IconName::ToolThink,
3350 acp::ToolKind::Fetch => IconName::ToolWeb,
3351 acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
3352 acp::ToolKind::Other | _ => IconName::ToolHammer,
3353 })
3354 .size(IconSize::Small)
3355 .color(Color::Muted),
3356 )
3357 .into_any_element()
3358 };
3359
3360 let gradient_overlay = {
3361 div()
3362 .absolute()
3363 .top_0()
3364 .right_0()
3365 .w_12()
3366 .h_full()
3367 .map(|this| {
3368 if use_card_layout {
3369 this.bg(linear_gradient(
3370 90.,
3371 linear_color_stop(self.tool_card_header_bg(cx), 1.),
3372 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
3373 ))
3374 } else {
3375 this.bg(linear_gradient(
3376 90.,
3377 linear_color_stop(cx.theme().colors().panel_background, 1.),
3378 linear_color_stop(
3379 cx.theme().colors().panel_background.opacity(0.2),
3380 0.,
3381 ),
3382 ))
3383 }
3384 })
3385 };
3386
3387 h_flex()
3388 .relative()
3389 .w_full()
3390 .h(window.line_height() - px(2.))
3391 .text_size(self.tool_name_font_size())
3392 .gap_1p5()
3393 .when(has_location || use_card_layout, |this| this.px_1())
3394 .when(has_location, |this| {
3395 this.cursor(CursorStyle::PointingHand)
3396 .rounded(rems_from_px(3.)) // Concentric border radius
3397 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
3398 })
3399 .overflow_hidden()
3400 .child(tool_icon)
3401 .child(if has_location {
3402 h_flex()
3403 .id(("open-tool-call-location", entry_ix))
3404 .w_full()
3405 .map(|this| {
3406 if use_card_layout {
3407 this.text_color(cx.theme().colors().text)
3408 } else {
3409 this.text_color(cx.theme().colors().text_muted)
3410 }
3411 })
3412 .child(self.render_markdown(
3413 tool_call.label.clone(),
3414 MarkdownStyle {
3415 prevent_mouse_interaction: true,
3416 ..default_markdown_style(false, true, window, cx)
3417 },
3418 ))
3419 .tooltip(Tooltip::text("Go to File"))
3420 .on_click(cx.listener(move |this, _, window, cx| {
3421 this.open_tool_call_location(entry_ix, 0, window, cx);
3422 }))
3423 .into_any_element()
3424 } else {
3425 h_flex()
3426 .w_full()
3427 .child(self.render_markdown(
3428 tool_call.label.clone(),
3429 default_markdown_style(false, true, window, cx),
3430 ))
3431 .into_any()
3432 })
3433 .when(!is_edit, |this| this.child(gradient_overlay))
3434 }
3435
3436 fn render_tool_call_content(
3437 &self,
3438 entry_ix: usize,
3439 content: &ToolCallContent,
3440 context_ix: usize,
3441 tool_call: &ToolCall,
3442 card_layout: bool,
3443 is_image_tool_call: bool,
3444 has_failed: bool,
3445 window: &Window,
3446 cx: &Context<Self>,
3447 ) -> AnyElement {
3448 match content {
3449 ToolCallContent::ContentBlock(content) => {
3450 if let Some(resource_link) = content.resource_link() {
3451 self.render_resource_link(resource_link, cx)
3452 } else if let Some(markdown) = content.markdown() {
3453 self.render_markdown_output(
3454 markdown.clone(),
3455 tool_call.id.clone(),
3456 context_ix,
3457 card_layout,
3458 window,
3459 cx,
3460 )
3461 } else if let Some(image) = content.image() {
3462 let location = tool_call.locations.first().cloned();
3463 self.render_image_output(
3464 entry_ix,
3465 image.clone(),
3466 location,
3467 card_layout,
3468 is_image_tool_call,
3469 cx,
3470 )
3471 } else {
3472 Empty.into_any_element()
3473 }
3474 }
3475 ToolCallContent::Diff(diff) => {
3476 self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx)
3477 }
3478 ToolCallContent::Terminal(terminal) => {
3479 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
3480 }
3481 ToolCallContent::SubagentThread(_thread) => {
3482 // Subagent threads are rendered by render_subagent_tool_call, not here
3483 Empty.into_any_element()
3484 }
3485 }
3486 }
3487
3488 fn render_subagent_tool_call(
3489 &self,
3490 entry_ix: usize,
3491 tool_call: &ToolCall,
3492 window: &Window,
3493 cx: &Context<Self>,
3494 ) -> Div {
3495 let subagent_threads: Vec<_> = tool_call
3496 .content
3497 .iter()
3498 .filter_map(|c| c.subagent_thread().cloned())
3499 .collect();
3500
3501 let tool_call_in_progress = matches!(
3502 tool_call.status,
3503 ToolCallStatus::Pending | ToolCallStatus::InProgress
3504 );
3505
3506 v_flex().ml_5().mr_5().my_1p5().gap_1().children(
3507 subagent_threads
3508 .into_iter()
3509 .enumerate()
3510 .map(|(context_ix, thread)| {
3511 self.render_subagent_card(
3512 entry_ix,
3513 context_ix,
3514 &thread,
3515 tool_call_in_progress,
3516 window,
3517 cx,
3518 )
3519 }),
3520 )
3521 }
3522
3523 fn render_subagent_card(
3524 &self,
3525 entry_ix: usize,
3526 context_ix: usize,
3527 thread: &Entity<AcpThread>,
3528 tool_call_in_progress: bool,
3529 window: &Window,
3530 cx: &Context<Self>,
3531 ) -> AnyElement {
3532 let thread_read = thread.read(cx);
3533 let session_id = thread_read.session_id().clone();
3534 let title = thread_read.title();
3535 let action_log = thread_read.action_log();
3536 let changed_buffers = action_log.read(cx).changed_buffers(cx);
3537
3538 let is_expanded = self.expanded_subagents.contains(&session_id);
3539 let files_changed = changed_buffers.len();
3540 let diff_stats = DiffStats::all_files(&changed_buffers, cx);
3541
3542 let is_running = tool_call_in_progress;
3543
3544 let card_header_id =
3545 SharedString::from(format!("subagent-header-{}-{}", entry_ix, context_ix));
3546 let diff_stat_id = SharedString::from(format!("subagent-diff-{}-{}", entry_ix, context_ix));
3547
3548 let icon = h_flex().w_4().justify_center().child(if is_running {
3549 SpinnerLabel::new()
3550 .size(LabelSize::Small)
3551 .into_any_element()
3552 } else {
3553 Icon::new(IconName::Check)
3554 .size(IconSize::Small)
3555 .color(Color::Success)
3556 .into_any_element()
3557 });
3558
3559 v_flex()
3560 .w_full()
3561 .rounded_md()
3562 .border_1()
3563 .border_color(self.tool_card_border_color(cx))
3564 .overflow_hidden()
3565 .child(
3566 h_flex()
3567 .group(&card_header_id)
3568 .py_1()
3569 .px_1p5()
3570 .w_full()
3571 .gap_1()
3572 .justify_between()
3573 .bg(self.tool_card_header_bg(cx))
3574 .child(
3575 h_flex()
3576 .gap_1p5()
3577 .child(icon)
3578 .child(
3579 Label::new(title.to_string())
3580 .size(LabelSize::Small)
3581 .color(Color::Default),
3582 )
3583 .when(files_changed > 0, |this| {
3584 this.child(
3585 h_flex()
3586 .gap_1()
3587 .child(
3588 Label::new(format!(
3589 "— {} {} changed",
3590 files_changed,
3591 if files_changed == 1 { "file" } else { "files" }
3592 ))
3593 .size(LabelSize::Small)
3594 .color(Color::Muted),
3595 )
3596 .child(DiffStat::new(
3597 diff_stat_id.clone(),
3598 diff_stats.lines_added as usize,
3599 diff_stats.lines_removed as usize,
3600 )),
3601 )
3602 }),
3603 )
3604 .child(
3605 Disclosure::new(
3606 SharedString::from(format!(
3607 "subagent-disclosure-inner-{}-{}",
3608 entry_ix, context_ix
3609 )),
3610 is_expanded,
3611 )
3612 .opened_icon(IconName::ChevronUp)
3613 .closed_icon(IconName::ChevronDown)
3614 .visible_on_hover(card_header_id)
3615 .on_click(cx.listener({
3616 move |this, _, _, cx| {
3617 if this.expanded_subagents.contains(&session_id) {
3618 this.expanded_subagents.remove(&session_id);
3619 } else {
3620 this.expanded_subagents.insert(session_id.clone());
3621 }
3622 cx.notify();
3623 }
3624 })),
3625 ),
3626 )
3627 .when(is_expanded, |this| {
3628 this.child(
3629 self.render_subagent_expanded_content(entry_ix, context_ix, thread, window, cx),
3630 )
3631 })
3632 .into_any_element()
3633 }
3634
3635 fn render_subagent_expanded_content(
3636 &self,
3637 _entry_ix: usize,
3638 _context_ix: usize,
3639 thread: &Entity<AcpThread>,
3640 window: &Window,
3641 cx: &Context<Self>,
3642 ) -> impl IntoElement {
3643 let thread_read = thread.read(cx);
3644 let session_id = thread_read.session_id().clone();
3645 let entries = thread_read.entries();
3646
3647 // Find the most recent agent message with any content (message or thought)
3648 let last_assistant_markdown = entries.iter().rev().find_map(|entry| {
3649 if let AgentThreadEntry::AssistantMessage(msg) = entry {
3650 msg.chunks.iter().find_map(|chunk| match chunk {
3651 AssistantMessageChunk::Message { block } => block.markdown().cloned(),
3652 AssistantMessageChunk::Thought { block } => block.markdown().cloned(),
3653 })
3654 } else {
3655 None
3656 }
3657 });
3658
3659 let scroll_handle = self
3660 .subagent_scroll_handles
3661 .borrow_mut()
3662 .entry(session_id.clone())
3663 .or_default()
3664 .clone();
3665
3666 scroll_handle.scroll_to_bottom();
3667
3668 div()
3669 .id(format!("subagent-content-{}", session_id))
3670 .w_full()
3671 .max_h_56()
3672 .p_2()
3673 .border_t_1()
3674 .border_color(self.tool_card_border_color(cx))
3675 .bg(cx.theme().colors().editor_background.opacity(0.2))
3676 .overflow_hidden()
3677 .track_scroll(&scroll_handle)
3678 .when_some(last_assistant_markdown, |this, markdown| {
3679 this.child(
3680 self.render_markdown(
3681 markdown,
3682 default_markdown_style(false, false, window, cx),
3683 ),
3684 )
3685 })
3686 }
3687
3688 fn render_markdown_output(
3689 &self,
3690 markdown: Entity<Markdown>,
3691 tool_call_id: acp::ToolCallId,
3692 context_ix: usize,
3693 card_layout: bool,
3694 window: &Window,
3695 cx: &Context<Self>,
3696 ) -> AnyElement {
3697 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
3698
3699 v_flex()
3700 .gap_2()
3701 .map(|this| {
3702 if card_layout {
3703 this.when(context_ix > 0, |this| {
3704 this.pt_2()
3705 .border_t_1()
3706 .border_color(self.tool_card_border_color(cx))
3707 })
3708 } else {
3709 this.ml(rems(0.4))
3710 .px_3p5()
3711 .border_l_1()
3712 .border_color(self.tool_card_border_color(cx))
3713 }
3714 })
3715 .text_xs()
3716 .text_color(cx.theme().colors().text_muted)
3717 .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
3718 .when(!card_layout, |this| {
3719 this.child(
3720 IconButton::new(button_id, IconName::ChevronUp)
3721 .full_width()
3722 .style(ButtonStyle::Outlined)
3723 .icon_color(Color::Muted)
3724 .on_click(cx.listener({
3725 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
3726 this.expanded_tool_calls.remove(&tool_call_id);
3727 cx.notify();
3728 }
3729 })),
3730 )
3731 })
3732 .into_any_element()
3733 }
3734
3735 fn render_image_output(
3736 &self,
3737 entry_ix: usize,
3738 image: Arc<gpui::Image>,
3739 location: Option<acp::ToolCallLocation>,
3740 card_layout: bool,
3741 show_dimensions: bool,
3742 cx: &Context<Self>,
3743 ) -> AnyElement {
3744 let dimensions_label = if show_dimensions {
3745 let format_name = match image.format() {
3746 gpui::ImageFormat::Png => "PNG",
3747 gpui::ImageFormat::Jpeg => "JPEG",
3748 gpui::ImageFormat::Webp => "WebP",
3749 gpui::ImageFormat::Gif => "GIF",
3750 gpui::ImageFormat::Svg => "SVG",
3751 gpui::ImageFormat::Bmp => "BMP",
3752 gpui::ImageFormat::Tiff => "TIFF",
3753 gpui::ImageFormat::Ico => "ICO",
3754 };
3755 let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
3756 .with_guessed_format()
3757 .ok()
3758 .and_then(|reader| reader.into_dimensions().ok());
3759 dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name))
3760 } else {
3761 None
3762 };
3763
3764 v_flex()
3765 .gap_2()
3766 .map(|this| {
3767 if card_layout {
3768 this
3769 } else {
3770 this.ml(rems(0.4))
3771 .px_3p5()
3772 .border_l_1()
3773 .border_color(self.tool_card_border_color(cx))
3774 }
3775 })
3776 .when(dimensions_label.is_some() || location.is_some(), |this| {
3777 this.child(
3778 h_flex()
3779 .w_full()
3780 .justify_between()
3781 .items_center()
3782 .children(dimensions_label.map(|label| {
3783 Label::new(label)
3784 .size(LabelSize::XSmall)
3785 .color(Color::Muted)
3786 .buffer_font(cx)
3787 }))
3788 .when_some(location, |this, _loc| {
3789 this.child(
3790 Button::new(("go-to-file", entry_ix), "Go to File")
3791 .label_size(LabelSize::Small)
3792 .on_click(cx.listener(move |this, _, window, cx| {
3793 this.open_tool_call_location(entry_ix, 0, window, cx);
3794 })),
3795 )
3796 }),
3797 )
3798 })
3799 .child(
3800 img(image)
3801 .max_w_96()
3802 .max_h_96()
3803 .object_fit(ObjectFit::ScaleDown),
3804 )
3805 .into_any_element()
3806 }
3807
3808 fn render_resource_link(
3809 &self,
3810 resource_link: &acp::ResourceLink,
3811 cx: &Context<Self>,
3812 ) -> AnyElement {
3813 let uri: SharedString = resource_link.uri.clone().into();
3814 let is_file = resource_link.uri.strip_prefix("file://");
3815
3816 let label: SharedString = if let Some(abs_path) = is_file {
3817 if let Some(project_path) = self
3818 .project
3819 .read(cx)
3820 .project_path_for_absolute_path(&Path::new(abs_path), cx)
3821 && let Some(worktree) = self
3822 .project
3823 .read(cx)
3824 .worktree_for_id(project_path.worktree_id, cx)
3825 {
3826 worktree
3827 .read(cx)
3828 .full_path(&project_path.path)
3829 .to_string_lossy()
3830 .to_string()
3831 .into()
3832 } else {
3833 abs_path.to_string().into()
3834 }
3835 } else {
3836 uri.clone()
3837 };
3838
3839 let button_id = SharedString::from(format!("item-{}", uri));
3840
3841 div()
3842 .ml(rems(0.4))
3843 .pl_2p5()
3844 .border_l_1()
3845 .border_color(self.tool_card_border_color(cx))
3846 .overflow_hidden()
3847 .child(
3848 Button::new(button_id, label)
3849 .label_size(LabelSize::Small)
3850 .color(Color::Muted)
3851 .truncate(true)
3852 .when(is_file.is_none(), |this| {
3853 this.icon(IconName::ArrowUpRight)
3854 .icon_size(IconSize::XSmall)
3855 .icon_color(Color::Muted)
3856 })
3857 .on_click(cx.listener({
3858 let workspace = self.workspace.clone();
3859 move |_, _, window, cx: &mut Context<Self>| {
3860 Self::open_link(uri.clone(), &workspace, window, cx);
3861 }
3862 })),
3863 )
3864 .into_any_element()
3865 }
3866
3867 fn render_permission_buttons(
3868 &self,
3869 kind: acp::ToolKind,
3870 options: &[acp::PermissionOption],
3871 entry_ix: usize,
3872 tool_call_id: acp::ToolCallId,
3873 cx: &Context<Self>,
3874 ) -> Div {
3875 let is_first = self.thread().is_some_and(|thread| {
3876 thread
3877 .read(cx)
3878 .first_tool_awaiting_confirmation()
3879 .is_some_and(|call| call.id == tool_call_id)
3880 });
3881
3882 // For SwitchMode, use the old layout with all buttons
3883 if kind == acp::ToolKind::SwitchMode {
3884 return self.render_permission_buttons_legacy(options, entry_ix, tool_call_id, cx);
3885 }
3886
3887 let granularity_options: Vec<_> = options
3888 .iter()
3889 .filter(|o| {
3890 matches!(
3891 o.kind,
3892 acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
3893 )
3894 })
3895 .collect();
3896
3897 // Get the selected granularity index, defaulting to the last option ("Only this time")
3898 let selected_index = self
3899 .selected_permission_granularity
3900 .get(&tool_call_id)
3901 .copied()
3902 .unwrap_or_else(|| granularity_options.len().saturating_sub(1));
3903
3904 let selected_option = granularity_options
3905 .get(selected_index)
3906 .or(granularity_options.last())
3907 .copied();
3908
3909 let dropdown_label: SharedString = selected_option
3910 .map(|o| o.name.clone().into())
3911 .unwrap_or_else(|| "Only this time".into());
3912
3913 let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) =
3914 if let Some(option) = selected_option {
3915 let option_id_str = option.option_id.0.to_string();
3916
3917 // Transform option_id for allow: "always:tool" -> "always_allow:tool", "once" -> "allow"
3918 let allow_id = if option_id_str == "once" {
3919 "allow".to_string()
3920 } else if let Some(rest) = option_id_str.strip_prefix("always:") {
3921 format!("always_allow:{}", rest)
3922 } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
3923 format!("always_allow_pattern:{}", rest)
3924 } else {
3925 option_id_str.clone()
3926 };
3927
3928 // Transform option_id for deny: "always:tool" -> "always_deny:tool", "once" -> "deny"
3929 let deny_id = if option_id_str == "once" {
3930 "deny".to_string()
3931 } else if let Some(rest) = option_id_str.strip_prefix("always:") {
3932 format!("always_deny:{}", rest)
3933 } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
3934 format!("always_deny_pattern:{}", rest)
3935 } else {
3936 option_id_str.replace("allow", "deny")
3937 };
3938
3939 let allow_kind = option.kind;
3940 let deny_kind = match option.kind {
3941 acp::PermissionOptionKind::AllowOnce => acp::PermissionOptionKind::RejectOnce,
3942 acp::PermissionOptionKind::AllowAlways => {
3943 acp::PermissionOptionKind::RejectAlways
3944 }
3945 other => other,
3946 };
3947
3948 (
3949 acp::PermissionOptionId::new(allow_id),
3950 allow_kind,
3951 acp::PermissionOptionId::new(deny_id),
3952 deny_kind,
3953 )
3954 } else {
3955 (
3956 acp::PermissionOptionId::new("allow"),
3957 acp::PermissionOptionKind::AllowOnce,
3958 acp::PermissionOptionId::new("deny"),
3959 acp::PermissionOptionKind::RejectOnce,
3960 )
3961 };
3962
3963 h_flex()
3964 .w_full()
3965 .p_1()
3966 .gap_2()
3967 .justify_between()
3968 .border_t_1()
3969 .border_color(self.tool_card_border_color(cx))
3970 .child(
3971 h_flex()
3972 .gap_0p5()
3973 .child(
3974 Button::new(("allow-btn", entry_ix), "Allow")
3975 .icon(IconName::Check)
3976 .icon_color(Color::Success)
3977 .icon_position(IconPosition::Start)
3978 .icon_size(IconSize::XSmall)
3979 .label_size(LabelSize::Small)
3980 .when(is_first, |this| {
3981 this.key_binding(
3982 KeyBinding::for_action_in(
3983 &AllowOnce as &dyn Action,
3984 &self.focus_handle,
3985 cx,
3986 )
3987 .map(|kb| kb.size(rems_from_px(10.))),
3988 )
3989 })
3990 .on_click(cx.listener({
3991 let tool_call_id = tool_call_id.clone();
3992 let option_id = allow_option_id;
3993 let option_kind = allow_option_kind;
3994 move |this, _, window, cx| {
3995 this.authorize_tool_call(
3996 tool_call_id.clone(),
3997 option_id.clone(),
3998 option_kind,
3999 window,
4000 cx,
4001 );
4002 }
4003 })),
4004 )
4005 .child(
4006 Button::new(("deny-btn", entry_ix), "Deny")
4007 .icon(IconName::Close)
4008 .icon_color(Color::Error)
4009 .icon_position(IconPosition::Start)
4010 .icon_size(IconSize::XSmall)
4011 .label_size(LabelSize::Small)
4012 .when(is_first, |this| {
4013 this.key_binding(
4014 KeyBinding::for_action_in(
4015 &RejectOnce as &dyn Action,
4016 &self.focus_handle,
4017 cx,
4018 )
4019 .map(|kb| kb.size(rems_from_px(10.))),
4020 )
4021 })
4022 .on_click(cx.listener({
4023 let tool_call_id = tool_call_id.clone();
4024 let option_id = deny_option_id;
4025 let option_kind = deny_option_kind;
4026 move |this, _, window, cx| {
4027 this.authorize_tool_call(
4028 tool_call_id.clone(),
4029 option_id.clone(),
4030 option_kind,
4031 window,
4032 cx,
4033 );
4034 }
4035 })),
4036 ),
4037 )
4038 .child(self.render_permission_granularity_dropdown(
4039 &granularity_options,
4040 dropdown_label,
4041 entry_ix,
4042 tool_call_id,
4043 selected_index,
4044 is_first,
4045 cx,
4046 ))
4047 }
4048
4049 fn render_permission_granularity_dropdown(
4050 &self,
4051 granularity_options: &[&acp::PermissionOption],
4052 current_label: SharedString,
4053 entry_ix: usize,
4054 tool_call_id: acp::ToolCallId,
4055 selected_index: usize,
4056 is_first: bool,
4057 cx: &Context<Self>,
4058 ) -> impl IntoElement {
4059 let menu_options: Vec<(usize, SharedString)> = granularity_options
4060 .iter()
4061 .enumerate()
4062 .map(|(i, o)| (i, o.name.clone().into()))
4063 .collect();
4064
4065 PopoverMenu::new(("permission-granularity", entry_ix))
4066 .with_handle(self.permission_dropdown_handle.clone())
4067 .trigger(
4068 Button::new(("granularity-trigger", entry_ix), current_label)
4069 .icon(IconName::ChevronDown)
4070 .icon_size(IconSize::XSmall)
4071 .icon_color(Color::Muted)
4072 .label_size(LabelSize::Small)
4073 .when(is_first, |this| {
4074 this.key_binding(
4075 KeyBinding::for_action_in(
4076 &crate::OpenPermissionDropdown as &dyn Action,
4077 &self.focus_handle,
4078 cx,
4079 )
4080 .map(|kb| kb.size(rems_from_px(10.))),
4081 )
4082 }),
4083 )
4084 .menu(move |window, cx| {
4085 let tool_call_id = tool_call_id.clone();
4086 let options = menu_options.clone();
4087
4088 Some(ContextMenu::build(window, cx, move |mut menu, _, _| {
4089 for (index, display_name) in options.iter() {
4090 let display_name = display_name.clone();
4091 let index = *index;
4092 let tool_call_id_for_entry = tool_call_id.clone();
4093 let is_selected = index == selected_index;
4094
4095 menu = menu.toggleable_entry(
4096 display_name,
4097 is_selected,
4098 IconPosition::End,
4099 None,
4100 move |window, cx| {
4101 window.dispatch_action(
4102 SelectPermissionGranularity {
4103 tool_call_id: tool_call_id_for_entry.0.to_string(),
4104 index,
4105 }
4106 .boxed_clone(),
4107 cx,
4108 );
4109 },
4110 );
4111 }
4112
4113 menu
4114 }))
4115 })
4116 }
4117
4118 fn render_permission_buttons_legacy(
4119 &self,
4120 options: &[acp::PermissionOption],
4121 entry_ix: usize,
4122 tool_call_id: acp::ToolCallId,
4123 cx: &Context<Self>,
4124 ) -> Div {
4125 let is_first = self.thread().is_some_and(|thread| {
4126 thread
4127 .read(cx)
4128 .first_tool_awaiting_confirmation()
4129 .is_some_and(|call| call.id == tool_call_id)
4130 });
4131 let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3> = ArrayVec::new();
4132
4133 div()
4134 .p_1()
4135 .border_t_1()
4136 .border_color(self.tool_card_border_color(cx))
4137 .w_full()
4138 .v_flex()
4139 .gap_0p5()
4140 .children(options.iter().map(move |option| {
4141 let option_id = SharedString::from(option.option_id.0.clone());
4142 Button::new((option_id, entry_ix), option.name.clone())
4143 .map(|this| {
4144 let (this, action) = match option.kind {
4145 acp::PermissionOptionKind::AllowOnce => (
4146 this.icon(IconName::Check).icon_color(Color::Success),
4147 Some(&AllowOnce as &dyn Action),
4148 ),
4149 acp::PermissionOptionKind::AllowAlways => (
4150 this.icon(IconName::CheckDouble).icon_color(Color::Success),
4151 Some(&AllowAlways as &dyn Action),
4152 ),
4153 acp::PermissionOptionKind::RejectOnce => (
4154 this.icon(IconName::Close).icon_color(Color::Error),
4155 Some(&RejectOnce as &dyn Action),
4156 ),
4157 acp::PermissionOptionKind::RejectAlways | _ => {
4158 (this.icon(IconName::Close).icon_color(Color::Error), None)
4159 }
4160 };
4161
4162 let Some(action) = action else {
4163 return this;
4164 };
4165
4166 if !is_first || seen_kinds.contains(&option.kind) {
4167 return this;
4168 }
4169
4170 seen_kinds.push(option.kind);
4171
4172 this.key_binding(
4173 KeyBinding::for_action_in(action, &self.focus_handle, cx)
4174 .map(|kb| kb.size(rems_from_px(10.))),
4175 )
4176 })
4177 .icon_position(IconPosition::Start)
4178 .icon_size(IconSize::XSmall)
4179 .label_size(LabelSize::Small)
4180 .on_click(cx.listener({
4181 let tool_call_id = tool_call_id.clone();
4182 let option_id = option.option_id.clone();
4183 let option_kind = option.kind;
4184 move |this, _, window, cx| {
4185 this.authorize_tool_call(
4186 tool_call_id.clone(),
4187 option_id.clone(),
4188 option_kind,
4189 window,
4190 cx,
4191 );
4192 }
4193 }))
4194 }))
4195 }
4196
4197 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
4198 let bar = |n: u64, width_class: &str| {
4199 let bg_color = cx.theme().colors().element_active;
4200 let base = h_flex().h_1().rounded_full();
4201
4202 let modified = match width_class {
4203 "w_4_5" => base.w_3_4(),
4204 "w_1_4" => base.w_1_4(),
4205 "w_2_4" => base.w_2_4(),
4206 "w_3_5" => base.w_3_5(),
4207 "w_2_5" => base.w_2_5(),
4208 _ => base.w_1_2(),
4209 };
4210
4211 modified.with_animation(
4212 ElementId::Integer(n),
4213 Animation::new(Duration::from_secs(2)).repeat(),
4214 move |tab, delta| {
4215 let delta = (delta - 0.15 * n as f32) / 0.7;
4216 let delta = 1.0 - (0.5 - delta).abs() * 2.;
4217 let delta = ease_in_out(delta.clamp(0., 1.));
4218 let delta = 0.1 + 0.9 * delta;
4219
4220 tab.bg(bg_color.opacity(delta))
4221 },
4222 )
4223 };
4224
4225 v_flex()
4226 .p_3()
4227 .gap_1()
4228 .rounded_b_md()
4229 .bg(cx.theme().colors().editor_background)
4230 .child(bar(0, "w_4_5"))
4231 .child(bar(1, "w_1_4"))
4232 .child(bar(2, "w_2_4"))
4233 .child(bar(3, "w_3_5"))
4234 .child(bar(4, "w_2_5"))
4235 .into_any_element()
4236 }
4237
4238 fn render_diff_editor(
4239 &self,
4240 entry_ix: usize,
4241 diff: &Entity<acp_thread::Diff>,
4242 tool_call: &ToolCall,
4243 has_failed: bool,
4244 cx: &Context<Self>,
4245 ) -> AnyElement {
4246 let tool_progress = matches!(
4247 &tool_call.status,
4248 ToolCallStatus::InProgress | ToolCallStatus::Pending
4249 );
4250
4251 let revealed_diff_editor = if let Some(entry) =
4252 self.entry_view_state.read(cx).entry(entry_ix)
4253 && let Some(editor) = entry.editor_for_diff(diff)
4254 && diff.read(cx).has_revealed_range(cx)
4255 {
4256 Some(editor)
4257 } else {
4258 None
4259 };
4260
4261 let show_top_border = !has_failed || revealed_diff_editor.is_some();
4262
4263 v_flex()
4264 .h_full()
4265 .when(show_top_border, |this| {
4266 this.border_t_1()
4267 .when(has_failed, |this| this.border_dashed())
4268 .border_color(self.tool_card_border_color(cx))
4269 })
4270 .child(if let Some(editor) = revealed_diff_editor {
4271 editor.into_any_element()
4272 } else if tool_progress && self.as_native_connection(cx).is_some() {
4273 self.render_diff_loading(cx)
4274 } else {
4275 Empty.into_any()
4276 })
4277 .into_any()
4278 }
4279
4280 /// Renders command lines with an optional expand/collapse button depending
4281 /// on the number of lines in `command_source`.
4282 fn render_collapsible_command(
4283 &self,
4284 is_preview: bool,
4285 command_source: &str,
4286 tool_call_id: &acp::ToolCallId,
4287 cx: &Context<Self>,
4288 ) -> Div {
4289 let expand_button_bg = self.tool_card_header_bg(cx);
4290 let expanded = self.expanded_terminal_commands.contains(tool_call_id);
4291
4292 let lines: Vec<&str> = command_source.lines().collect();
4293 let line_count = lines.len();
4294 let extra_lines = line_count.saturating_sub(MAX_COLLAPSED_LINES);
4295
4296 let show_expand_button = extra_lines > 0;
4297
4298 let max_lines = if expanded || !show_expand_button {
4299 usize::MAX
4300 } else {
4301 MAX_COLLAPSED_LINES
4302 };
4303
4304 let display_lines = lines.into_iter().take(max_lines);
4305
4306 let command_group =
4307 SharedString::from(format!("collapsible-command-group-{}", tool_call_id));
4308
4309 v_flex()
4310 .group(command_group.clone())
4311 .bg(self.tool_card_header_bg(cx))
4312 .child(
4313 v_flex()
4314 .p_1p5()
4315 .when(is_preview, |this| {
4316 this.pt_1().child(
4317 // Wrapping this label on a container with 24px height to avoid
4318 // layout shift when it changes from being a preview label
4319 // to the actual path where the command will run in
4320 h_flex().h_6().child(
4321 Label::new("Run Command")
4322 .buffer_font(cx)
4323 .size(LabelSize::XSmall)
4324 .color(Color::Muted),
4325 ),
4326 )
4327 })
4328 .children(display_lines.map(|line| {
4329 let text: SharedString = if line.is_empty() {
4330 " ".into()
4331 } else {
4332 line.to_string().into()
4333 };
4334
4335 Label::new(text).buffer_font(cx).size(LabelSize::Small)
4336 }))
4337 .child(
4338 div().absolute().top_1().right_1().child(
4339 CopyButton::new(command_source.to_string())
4340 .tooltip_label("Copy Command")
4341 .visible_on_hover(command_group),
4342 ),
4343 ),
4344 )
4345 .when(show_expand_button, |this| {
4346 let expand_icon = if expanded {
4347 IconName::ChevronUp
4348 } else {
4349 IconName::ChevronDown
4350 };
4351
4352 this.child(
4353 h_flex()
4354 .id(format!("expand-command-btn-{}", tool_call_id))
4355 .cursor_pointer()
4356 .when(!expanded, |s| s.absolute().bottom_0())
4357 .when(expanded, |s| s.mt_1())
4358 .w_full()
4359 .h_6()
4360 .gap_1()
4361 .justify_center()
4362 .border_t_1()
4363 .border_color(self.tool_card_border_color(cx))
4364 .bg(expand_button_bg.opacity(0.95))
4365 .hover(|s| s.bg(cx.theme().colors().element_hover))
4366 .when(!expanded, |this| {
4367 let label = match extra_lines {
4368 1 => "1 more line".to_string(),
4369 _ => format!("{} more lines", extra_lines),
4370 };
4371
4372 this.child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
4373 })
4374 .child(
4375 Icon::new(expand_icon)
4376 .size(IconSize::Small)
4377 .color(Color::Muted),
4378 )
4379 .on_click(cx.listener({
4380 let tool_call_id = tool_call_id.clone();
4381 move |this, _event, _window, cx| {
4382 if expanded {
4383 this.expanded_terminal_commands.remove(&tool_call_id);
4384 } else {
4385 this.expanded_terminal_commands.insert(tool_call_id.clone());
4386 }
4387 cx.notify();
4388 }
4389 })),
4390 )
4391 })
4392 }
4393
4394 fn render_terminal_tool_call(
4395 &self,
4396 entry_ix: usize,
4397 terminal: &Entity<acp_thread::Terminal>,
4398 tool_call: &ToolCall,
4399 window: &Window,
4400 cx: &Context<Self>,
4401 ) -> AnyElement {
4402 let terminal_data = terminal.read(cx);
4403 let working_dir = terminal_data.working_dir();
4404 let command = terminal_data.command();
4405 let started_at = terminal_data.started_at();
4406
4407 let tool_failed = matches!(
4408 &tool_call.status,
4409 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
4410 );
4411
4412 let output = terminal_data.output();
4413 let command_finished = output.is_some();
4414 let truncated_output =
4415 output.is_some_and(|output| output.original_content_len > output.content.len());
4416 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
4417
4418 let command_failed = command_finished
4419 && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
4420
4421 let time_elapsed = if let Some(output) = output {
4422 output.ended_at.duration_since(started_at)
4423 } else {
4424 started_at.elapsed()
4425 };
4426
4427 let header_id =
4428 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
4429 let header_group = SharedString::from(format!(
4430 "terminal-tool-header-group-{}",
4431 terminal.entity_id()
4432 ));
4433 let header_bg = cx
4434 .theme()
4435 .colors()
4436 .element_background
4437 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
4438 let border_color = cx.theme().colors().border.opacity(0.6);
4439
4440 let working_dir = working_dir
4441 .as_ref()
4442 .map(|path| path.display().to_string())
4443 .unwrap_or_else(|| "current directory".to_string());
4444
4445 // Since the command's source is wrapped in a markdown code block
4446 // (```\n...\n```), we need to strip that so we're left with only the
4447 // command's content.
4448 let command_source = command.read(cx).source();
4449 let command_content = command_source
4450 .strip_prefix("```\n")
4451 .and_then(|s| s.strip_suffix("\n```"))
4452 .unwrap_or(&command_source);
4453
4454 let command_element =
4455 self.render_collapsible_command(false, command_content, &tool_call.id, cx);
4456
4457 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
4458
4459 let header = h_flex()
4460 .id(header_id)
4461 .px_1p5()
4462 .pt_1()
4463 .flex_none()
4464 .gap_1()
4465 .justify_between()
4466 .rounded_t_md()
4467 .child(
4468 div()
4469 .id(("command-target-path", terminal.entity_id()))
4470 .w_full()
4471 .max_w_full()
4472 .overflow_x_scroll()
4473 .child(
4474 Label::new(working_dir)
4475 .buffer_font(cx)
4476 .size(LabelSize::XSmall)
4477 .color(Color::Muted),
4478 ),
4479 )
4480 .when(!command_finished, |header| {
4481 header
4482 .gap_1p5()
4483 .child(
4484 Button::new(
4485 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
4486 "Stop",
4487 )
4488 .icon(IconName::Stop)
4489 .icon_position(IconPosition::Start)
4490 .icon_size(IconSize::Small)
4491 .icon_color(Color::Error)
4492 .label_size(LabelSize::Small)
4493 .tooltip(move |_window, cx| {
4494 Tooltip::with_meta(
4495 "Stop This Command",
4496 None,
4497 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
4498 cx,
4499 )
4500 })
4501 .on_click({
4502 let terminal = terminal.clone();
4503 cx.listener(move |this, _event, _window, cx| {
4504 terminal.update(cx, |terminal, cx| {
4505 terminal.stop_by_user(cx);
4506 });
4507 this.cancel_generation(cx);
4508 })
4509 }),
4510 )
4511 .child(Divider::vertical())
4512 .child(
4513 Icon::new(IconName::ArrowCircle)
4514 .size(IconSize::XSmall)
4515 .color(Color::Info)
4516 .with_rotate_animation(2)
4517 )
4518 })
4519 .when(truncated_output, |header| {
4520 let tooltip = if let Some(output) = output {
4521 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
4522 format!("Output exceeded terminal max lines and was \
4523 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
4524 } else {
4525 format!(
4526 "Output is {} long, and to avoid unexpected token usage, \
4527 only {} was sent back to the agent.",
4528 format_file_size(output.original_content_len as u64, true),
4529 format_file_size(output.content.len() as u64, true)
4530 )
4531 }
4532 } else {
4533 "Output was truncated".to_string()
4534 };
4535
4536 header.child(
4537 h_flex()
4538 .id(("terminal-tool-truncated-label", terminal.entity_id()))
4539 .gap_1()
4540 .child(
4541 Icon::new(IconName::Info)
4542 .size(IconSize::XSmall)
4543 .color(Color::Ignored),
4544 )
4545 .child(
4546 Label::new("Truncated")
4547 .color(Color::Muted)
4548 .size(LabelSize::XSmall),
4549 )
4550 .tooltip(Tooltip::text(tooltip)),
4551 )
4552 })
4553 .when(time_elapsed > Duration::from_secs(10), |header| {
4554 header.child(
4555 Label::new(format!("({})", duration_alt_display(time_elapsed)))
4556 .buffer_font(cx)
4557 .color(Color::Muted)
4558 .size(LabelSize::XSmall),
4559 )
4560 })
4561 .when(tool_failed || command_failed, |header| {
4562 header.child(
4563 div()
4564 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
4565 .child(
4566 Icon::new(IconName::Close)
4567 .size(IconSize::Small)
4568 .color(Color::Error),
4569 )
4570 .when_some(output.and_then(|o| o.exit_status), |this, status| {
4571 this.tooltip(Tooltip::text(format!(
4572 "Exited with code {}",
4573 status.code().unwrap_or(-1),
4574 )))
4575 }),
4576 )
4577 })
4578 .child(
4579 Disclosure::new(
4580 SharedString::from(format!(
4581 "terminal-tool-disclosure-{}",
4582 terminal.entity_id()
4583 )),
4584 is_expanded,
4585 )
4586 .opened_icon(IconName::ChevronUp)
4587 .closed_icon(IconName::ChevronDown)
4588 .visible_on_hover(&header_group)
4589 .on_click(cx.listener({
4590 let id = tool_call.id.clone();
4591 move |this, _event, _window, _cx| {
4592 if is_expanded {
4593 this.expanded_tool_calls.remove(&id);
4594 } else {
4595 this.expanded_tool_calls.insert(id.clone());
4596 }
4597 }
4598 })),
4599 );
4600
4601 let terminal_view = self
4602 .entry_view_state
4603 .read(cx)
4604 .entry(entry_ix)
4605 .and_then(|entry| entry.terminal(terminal));
4606
4607 v_flex()
4608 .my_1p5()
4609 .mx_5()
4610 .border_1()
4611 .when(tool_failed || command_failed, |card| card.border_dashed())
4612 .border_color(border_color)
4613 .rounded_md()
4614 .overflow_hidden()
4615 .child(
4616 v_flex()
4617 .group(&header_group)
4618 .bg(header_bg)
4619 .text_xs()
4620 .child(header)
4621 .child(command_element),
4622 )
4623 .when(is_expanded && terminal_view.is_some(), |this| {
4624 this.child(
4625 div()
4626 .pt_2()
4627 .border_t_1()
4628 .when(tool_failed || command_failed, |card| card.border_dashed())
4629 .border_color(border_color)
4630 .bg(cx.theme().colors().editor_background)
4631 .rounded_b_md()
4632 .text_ui_sm(cx)
4633 .h_full()
4634 .children(terminal_view.map(|terminal_view| {
4635 let element = if terminal_view
4636 .read(cx)
4637 .content_mode(window, cx)
4638 .is_scrollable()
4639 {
4640 div().h_72().child(terminal_view).into_any_element()
4641 } else {
4642 terminal_view.into_any_element()
4643 };
4644
4645 div()
4646 .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| {
4647 window.dispatch_action(NewThread.boxed_clone(), cx);
4648 cx.stop_propagation();
4649 }))
4650 .child(element)
4651 .into_any_element()
4652 })),
4653 )
4654 })
4655 .into_any()
4656 }
4657
4658 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
4659 let project_context = self
4660 .as_native_thread(cx)?
4661 .read(cx)
4662 .project_context()
4663 .read(cx);
4664
4665 let user_rules_text = if project_context.user_rules.is_empty() {
4666 None
4667 } else if project_context.user_rules.len() == 1 {
4668 let user_rules = &project_context.user_rules[0];
4669
4670 match user_rules.title.as_ref() {
4671 Some(title) => Some(format!("Using \"{title}\" user rule")),
4672 None => Some("Using user rule".into()),
4673 }
4674 } else {
4675 Some(format!(
4676 "Using {} user rules",
4677 project_context.user_rules.len()
4678 ))
4679 };
4680
4681 let first_user_rules_id = project_context
4682 .user_rules
4683 .first()
4684 .map(|user_rules| user_rules.uuid.0);
4685
4686 let rules_files = project_context
4687 .worktrees
4688 .iter()
4689 .filter_map(|worktree| worktree.rules_file.as_ref())
4690 .collect::<Vec<_>>();
4691
4692 let rules_file_text = match rules_files.as_slice() {
4693 &[] => None,
4694 &[rules_file] => Some(format!(
4695 "Using project {:?} file",
4696 rules_file.path_in_worktree
4697 )),
4698 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
4699 };
4700
4701 if user_rules_text.is_none() && rules_file_text.is_none() {
4702 return None;
4703 }
4704
4705 let has_both = user_rules_text.is_some() && rules_file_text.is_some();
4706
4707 Some(
4708 h_flex()
4709 .px_2p5()
4710 .child(
4711 Icon::new(IconName::Attach)
4712 .size(IconSize::XSmall)
4713 .color(Color::Disabled),
4714 )
4715 .when_some(user_rules_text, |parent, user_rules_text| {
4716 parent.child(
4717 h_flex()
4718 .id("user-rules")
4719 .ml_1()
4720 .mr_1p5()
4721 .child(
4722 Label::new(user_rules_text)
4723 .size(LabelSize::XSmall)
4724 .color(Color::Muted)
4725 .truncate(),
4726 )
4727 .hover(|s| s.bg(cx.theme().colors().element_hover))
4728 .tooltip(Tooltip::text("View User Rules"))
4729 .on_click(move |_event, window, cx| {
4730 window.dispatch_action(
4731 Box::new(OpenRulesLibrary {
4732 prompt_to_select: first_user_rules_id,
4733 }),
4734 cx,
4735 )
4736 }),
4737 )
4738 })
4739 .when(has_both, |this| {
4740 this.child(
4741 Label::new("•")
4742 .size(LabelSize::XSmall)
4743 .color(Color::Disabled),
4744 )
4745 })
4746 .when_some(rules_file_text, |parent, rules_file_text| {
4747 parent.child(
4748 h_flex()
4749 .id("project-rules")
4750 .ml_1p5()
4751 .child(
4752 Label::new(rules_file_text)
4753 .size(LabelSize::XSmall)
4754 .color(Color::Muted),
4755 )
4756 .hover(|s| s.bg(cx.theme().colors().element_hover))
4757 .tooltip(Tooltip::text("View Project Rules"))
4758 .on_click(cx.listener(Self::handle_open_rules)),
4759 )
4760 })
4761 .into_any(),
4762 )
4763 }
4764
4765 fn render_empty_state_section_header(
4766 &self,
4767 label: impl Into<SharedString>,
4768 action_slot: Option<AnyElement>,
4769 cx: &mut Context<Self>,
4770 ) -> impl IntoElement {
4771 div().pl_1().pr_1p5().child(
4772 h_flex()
4773 .mt_2()
4774 .pl_1p5()
4775 .pb_1()
4776 .w_full()
4777 .justify_between()
4778 .border_b_1()
4779 .border_color(cx.theme().colors().border_variant)
4780 .child(
4781 Label::new(label.into())
4782 .size(LabelSize::Small)
4783 .color(Color::Muted),
4784 )
4785 .children(action_slot),
4786 )
4787 }
4788
4789 fn update_recent_history_from_cache(
4790 &mut self,
4791 history: &Entity<AcpThreadHistory>,
4792 cx: &mut Context<Self>,
4793 ) {
4794 self.recent_history_entries = history.read(cx).get_recent_sessions(3);
4795 self.hovered_recent_history_item = None;
4796 cx.notify();
4797 }
4798
4799 fn render_recent_history(&self, cx: &mut Context<Self>) -> AnyElement {
4800 let render_history = !self.recent_history_entries.is_empty();
4801
4802 v_flex()
4803 .size_full()
4804 .when(render_history, |this| {
4805 let recent_history = self.recent_history_entries.clone();
4806 this.justify_end().child(
4807 v_flex()
4808 .child(
4809 self.render_empty_state_section_header(
4810 "Recent",
4811 Some(
4812 Button::new("view-history", "View All")
4813 .style(ButtonStyle::Subtle)
4814 .label_size(LabelSize::Small)
4815 .key_binding(
4816 KeyBinding::for_action_in(
4817 &OpenHistory,
4818 &self.focus_handle(cx),
4819 cx,
4820 )
4821 .map(|kb| kb.size(rems_from_px(12.))),
4822 )
4823 .on_click(move |_event, window, cx| {
4824 window.dispatch_action(OpenHistory.boxed_clone(), cx);
4825 })
4826 .into_any_element(),
4827 ),
4828 cx,
4829 ),
4830 )
4831 .child(v_flex().p_1().pr_1p5().gap_1().children({
4832 let supports_delete = self.history.read(cx).supports_delete();
4833 recent_history
4834 .into_iter()
4835 .enumerate()
4836 .map(move |(index, entry)| {
4837 // TODO: Add keyboard navigation.
4838 let is_hovered =
4839 self.hovered_recent_history_item == Some(index);
4840 crate::acp::thread_history::AcpHistoryEntryElement::new(
4841 entry,
4842 cx.entity().downgrade(),
4843 )
4844 .hovered(is_hovered)
4845 .supports_delete(supports_delete)
4846 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
4847 if *is_hovered {
4848 this.hovered_recent_history_item = Some(index);
4849 } else if this.hovered_recent_history_item == Some(index) {
4850 this.hovered_recent_history_item = None;
4851 }
4852 cx.notify();
4853 }))
4854 .into_any_element()
4855 })
4856 })),
4857 )
4858 })
4859 .into_any()
4860 }
4861
4862 fn render_auth_required_state(
4863 &self,
4864 connection: &Rc<dyn AgentConnection>,
4865 description: Option<&Entity<Markdown>>,
4866 configuration_view: Option<&AnyView>,
4867 pending_auth_method: Option<&acp::AuthMethodId>,
4868 window: &mut Window,
4869 cx: &Context<Self>,
4870 ) -> impl IntoElement {
4871 let auth_methods = connection.auth_methods();
4872
4873 let agent_display_name = self
4874 .agent_server_store
4875 .read(cx)
4876 .agent_display_name(&ExternalAgentServerName(self.agent.name()))
4877 .unwrap_or_else(|| self.agent.name());
4878
4879 let show_fallback_description = auth_methods.len() > 1
4880 && configuration_view.is_none()
4881 && description.is_none()
4882 && pending_auth_method.is_none();
4883
4884 let auth_buttons = || {
4885 h_flex().justify_end().flex_wrap().gap_1().children(
4886 connection
4887 .auth_methods()
4888 .iter()
4889 .enumerate()
4890 .rev()
4891 .map(|(ix, method)| {
4892 let (method_id, name) = if self.project.read(cx).is_via_remote_server()
4893 && method.id.0.as_ref() == "oauth-personal"
4894 && method.name == "Log in with Google"
4895 {
4896 ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
4897 } else {
4898 (method.id.0.clone(), method.name.clone())
4899 };
4900
4901 let agent_telemetry_id = connection.telemetry_id();
4902
4903 Button::new(method_id.clone(), name)
4904 .label_size(LabelSize::Small)
4905 .map(|this| {
4906 if ix == 0 {
4907 this.style(ButtonStyle::Tinted(TintColor::Accent))
4908 } else {
4909 this.style(ButtonStyle::Outlined)
4910 }
4911 })
4912 .when_some(method.description.clone(), |this, description| {
4913 this.tooltip(Tooltip::text(description))
4914 })
4915 .on_click({
4916 cx.listener(move |this, _, window, cx| {
4917 telemetry::event!(
4918 "Authenticate Agent Started",
4919 agent = agent_telemetry_id,
4920 method = method_id
4921 );
4922
4923 this.authenticate(
4924 acp::AuthMethodId::new(method_id.clone()),
4925 window,
4926 cx,
4927 )
4928 })
4929 })
4930 }),
4931 )
4932 };
4933
4934 if pending_auth_method.is_some() {
4935 return Callout::new()
4936 .icon(IconName::Info)
4937 .title(format!("Authenticating to {}…", agent_display_name))
4938 .actions_slot(
4939 Icon::new(IconName::ArrowCircle)
4940 .size(IconSize::Small)
4941 .color(Color::Muted)
4942 .with_rotate_animation(2)
4943 .into_any_element(),
4944 )
4945 .into_any_element();
4946 }
4947
4948 Callout::new()
4949 .icon(IconName::Info)
4950 .title(format!("Authenticate to {}", agent_display_name))
4951 .when(auth_methods.len() == 1, |this| {
4952 this.actions_slot(auth_buttons())
4953 })
4954 .description_slot(
4955 v_flex()
4956 .text_ui(cx)
4957 .map(|this| {
4958 if show_fallback_description {
4959 this.child(
4960 Label::new("Choose one of the following authentication options:")
4961 .size(LabelSize::Small)
4962 .color(Color::Muted),
4963 )
4964 } else {
4965 this.children(
4966 configuration_view
4967 .cloned()
4968 .map(|view| div().w_full().child(view)),
4969 )
4970 .children(description.map(|desc| {
4971 self.render_markdown(
4972 desc.clone(),
4973 default_markdown_style(false, false, window, cx),
4974 )
4975 }))
4976 }
4977 })
4978 .when(auth_methods.len() > 1, |this| {
4979 this.gap_1().child(auth_buttons())
4980 }),
4981 )
4982 .into_any_element()
4983 }
4984
4985 fn render_load_error(
4986 &self,
4987 e: &LoadError,
4988 window: &mut Window,
4989 cx: &mut Context<Self>,
4990 ) -> AnyElement {
4991 let (title, message, action_slot): (_, SharedString, _) = match e {
4992 LoadError::Unsupported {
4993 command: path,
4994 current_version,
4995 minimum_version,
4996 } => {
4997 return self.render_unsupported(path, current_version, minimum_version, window, cx);
4998 }
4999 LoadError::FailedToInstall(msg) => (
5000 "Failed to Install",
5001 msg.into(),
5002 Some(self.create_copy_button(msg.to_string()).into_any_element()),
5003 ),
5004 LoadError::Exited { status } => (
5005 "Failed to Launch",
5006 format!("Server exited with status {status}").into(),
5007 None,
5008 ),
5009 LoadError::Other(msg) => (
5010 "Failed to Launch",
5011 msg.into(),
5012 Some(self.create_copy_button(msg.to_string()).into_any_element()),
5013 ),
5014 };
5015
5016 Callout::new()
5017 .severity(Severity::Error)
5018 .icon(IconName::XCircleFilled)
5019 .title(title)
5020 .description(message)
5021 .actions_slot(div().children(action_slot))
5022 .into_any_element()
5023 }
5024
5025 fn render_unsupported(
5026 &self,
5027 path: &SharedString,
5028 version: &SharedString,
5029 minimum_version: &SharedString,
5030 _window: &mut Window,
5031 cx: &mut Context<Self>,
5032 ) -> AnyElement {
5033 let (heading_label, description_label) = (
5034 format!("Upgrade {} to work with Zed", self.agent.name()),
5035 if version.is_empty() {
5036 format!(
5037 "Currently using {}, which does not report a valid --version",
5038 path,
5039 )
5040 } else {
5041 format!(
5042 "Currently using {}, which is only version {} (need at least {minimum_version})",
5043 path, version
5044 )
5045 },
5046 );
5047
5048 v_flex()
5049 .w_full()
5050 .p_3p5()
5051 .gap_2p5()
5052 .border_t_1()
5053 .border_color(cx.theme().colors().border)
5054 .bg(linear_gradient(
5055 180.,
5056 linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
5057 linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
5058 ))
5059 .child(
5060 v_flex().gap_0p5().child(Label::new(heading_label)).child(
5061 Label::new(description_label)
5062 .size(LabelSize::Small)
5063 .color(Color::Muted),
5064 ),
5065 )
5066 .into_any_element()
5067 }
5068
5069 fn activity_bar_bg(&self, cx: &Context<Self>) -> Hsla {
5070 let editor_bg_color = cx.theme().colors().editor_background;
5071 let active_color = cx.theme().colors().element_selected;
5072 editor_bg_color.blend(active_color.opacity(0.3))
5073 }
5074
5075 fn render_activity_bar(
5076 &self,
5077 thread_entity: &Entity<AcpThread>,
5078 window: &mut Window,
5079 cx: &Context<Self>,
5080 ) -> Option<AnyElement> {
5081 let thread = thread_entity.read(cx);
5082 let action_log = thread.action_log();
5083 let telemetry = ActionLogTelemetry::from(thread);
5084 let changed_buffers = action_log.read(cx).changed_buffers(cx);
5085 let plan = thread.plan();
5086
5087 if changed_buffers.is_empty() && plan.is_empty() && self.message_queue.is_empty() {
5088 return None;
5089 }
5090
5091 // Temporarily always enable ACP edit controls. This is temporary, to lessen the
5092 // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
5093 // be, which blocks you from being able to accept or reject edits. This switches the
5094 // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
5095 // block you from using the panel.
5096 let pending_edits = false;
5097
5098 let use_keep_reject_buttons = !cx.has_flag::<AgentV2FeatureFlag>();
5099
5100 v_flex()
5101 .mt_1()
5102 .mx_2()
5103 .bg(self.activity_bar_bg(cx))
5104 .border_1()
5105 .border_b_0()
5106 .border_color(cx.theme().colors().border)
5107 .rounded_t_md()
5108 .shadow(vec![gpui::BoxShadow {
5109 color: gpui::black().opacity(0.15),
5110 offset: point(px(1.), px(-1.)),
5111 blur_radius: px(3.),
5112 spread_radius: px(0.),
5113 }])
5114 .when(!plan.is_empty(), |this| {
5115 this.child(self.render_plan_summary(plan, window, cx))
5116 .when(self.plan_expanded, |parent| {
5117 parent.child(self.render_plan_entries(plan, window, cx))
5118 })
5119 })
5120 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
5121 this.child(Divider::horizontal().color(DividerColor::Border))
5122 })
5123 .when(!changed_buffers.is_empty(), |this| {
5124 this.child(self.render_edits_summary(
5125 &changed_buffers,
5126 self.edits_expanded,
5127 pending_edits,
5128 use_keep_reject_buttons,
5129 cx,
5130 ))
5131 .when(self.edits_expanded, |parent| {
5132 parent.child(self.render_edited_files(
5133 action_log,
5134 telemetry.clone(),
5135 &changed_buffers,
5136 pending_edits,
5137 use_keep_reject_buttons,
5138 cx,
5139 ))
5140 })
5141 })
5142 .when(!self.message_queue.is_empty(), |this| {
5143 this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| {
5144 this.child(Divider::horizontal().color(DividerColor::Border))
5145 })
5146 .child(self.render_message_queue_summary(window, cx))
5147 .when(self.queue_expanded, |parent| {
5148 parent.child(self.render_message_queue_entries(window, cx))
5149 })
5150 })
5151 .into_any()
5152 .into()
5153 }
5154
5155 fn render_plan_summary(
5156 &self,
5157 plan: &Plan,
5158 window: &mut Window,
5159 cx: &Context<Self>,
5160 ) -> impl IntoElement {
5161 let stats = plan.stats();
5162
5163 let title = if let Some(entry) = stats.in_progress_entry
5164 && !self.plan_expanded
5165 {
5166 h_flex()
5167 .cursor_default()
5168 .relative()
5169 .w_full()
5170 .gap_1()
5171 .truncate()
5172 .child(
5173 Label::new("Current:")
5174 .size(LabelSize::Small)
5175 .color(Color::Muted),
5176 )
5177 .child(
5178 div()
5179 .text_xs()
5180 .text_color(cx.theme().colors().text_muted)
5181 .line_clamp(1)
5182 .child(MarkdownElement::new(
5183 entry.content.clone(),
5184 plan_label_markdown_style(&entry.status, window, cx),
5185 )),
5186 )
5187 .when(stats.pending > 0, |this| {
5188 this.child(
5189 h_flex()
5190 .absolute()
5191 .top_0()
5192 .right_0()
5193 .h_full()
5194 .child(div().min_w_8().h_full().bg(linear_gradient(
5195 90.,
5196 linear_color_stop(self.activity_bar_bg(cx), 1.),
5197 linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.),
5198 )))
5199 .child(
5200 div().pr_0p5().bg(self.activity_bar_bg(cx)).child(
5201 Label::new(format!("{} left", stats.pending))
5202 .size(LabelSize::Small)
5203 .color(Color::Muted),
5204 ),
5205 ),
5206 )
5207 })
5208 } else {
5209 let status_label = if stats.pending == 0 {
5210 "All Done".to_string()
5211 } else if stats.completed == 0 {
5212 format!("{} Tasks", plan.entries.len())
5213 } else {
5214 format!("{}/{}", stats.completed, plan.entries.len())
5215 };
5216
5217 h_flex()
5218 .w_full()
5219 .gap_1()
5220 .justify_between()
5221 .child(
5222 Label::new("Plan")
5223 .size(LabelSize::Small)
5224 .color(Color::Muted),
5225 )
5226 .child(
5227 Label::new(status_label)
5228 .size(LabelSize::Small)
5229 .color(Color::Muted)
5230 .mr_1(),
5231 )
5232 };
5233
5234 h_flex()
5235 .id("plan_summary")
5236 .p_1()
5237 .w_full()
5238 .gap_1()
5239 .when(self.plan_expanded, |this| {
5240 this.border_b_1().border_color(cx.theme().colors().border)
5241 })
5242 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
5243 .child(title)
5244 .on_click(cx.listener(|this, _, _, cx| {
5245 this.plan_expanded = !this.plan_expanded;
5246 cx.notify();
5247 }))
5248 }
5249
5250 fn render_plan_entries(
5251 &self,
5252 plan: &Plan,
5253 window: &mut Window,
5254 cx: &Context<Self>,
5255 ) -> impl IntoElement {
5256 v_flex()
5257 .id("plan_items_list")
5258 .max_h_40()
5259 .overflow_y_scroll()
5260 .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
5261 let element = h_flex()
5262 .py_1()
5263 .px_2()
5264 .gap_2()
5265 .justify_between()
5266 .bg(cx.theme().colors().editor_background)
5267 .when(index < plan.entries.len() - 1, |parent| {
5268 parent.border_color(cx.theme().colors().border).border_b_1()
5269 })
5270 .child(
5271 h_flex()
5272 .id(("plan_entry", index))
5273 .gap_1p5()
5274 .max_w_full()
5275 .overflow_x_scroll()
5276 .text_xs()
5277 .text_color(cx.theme().colors().text_muted)
5278 .child(match entry.status {
5279 acp::PlanEntryStatus::InProgress => {
5280 Icon::new(IconName::TodoProgress)
5281 .size(IconSize::Small)
5282 .color(Color::Accent)
5283 .with_rotate_animation(2)
5284 .into_any_element()
5285 }
5286 acp::PlanEntryStatus::Completed => {
5287 Icon::new(IconName::TodoComplete)
5288 .size(IconSize::Small)
5289 .color(Color::Success)
5290 .into_any_element()
5291 }
5292 acp::PlanEntryStatus::Pending | _ => {
5293 Icon::new(IconName::TodoPending)
5294 .size(IconSize::Small)
5295 .color(Color::Muted)
5296 .into_any_element()
5297 }
5298 })
5299 .child(MarkdownElement::new(
5300 entry.content.clone(),
5301 plan_label_markdown_style(&entry.status, window, cx),
5302 )),
5303 );
5304
5305 Some(element)
5306 }))
5307 .into_any_element()
5308 }
5309
5310 fn render_edits_summary(
5311 &self,
5312 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
5313 expanded: bool,
5314 pending_edits: bool,
5315 use_keep_reject_buttons: bool,
5316 cx: &Context<Self>,
5317 ) -> Div {
5318 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
5319
5320 let focus_handle = self.focus_handle(cx);
5321
5322 h_flex()
5323 .p_1()
5324 .justify_between()
5325 .flex_wrap()
5326 .when(expanded, |this| {
5327 this.border_b_1().border_color(cx.theme().colors().border)
5328 })
5329 .child(
5330 h_flex()
5331 .id("edits-container")
5332 .cursor_pointer()
5333 .gap_1()
5334 .child(Disclosure::new("edits-disclosure", expanded))
5335 .map(|this| {
5336 if pending_edits {
5337 this.child(
5338 Label::new(format!(
5339 "Editing {} {}…",
5340 changed_buffers.len(),
5341 if changed_buffers.len() == 1 {
5342 "file"
5343 } else {
5344 "files"
5345 }
5346 ))
5347 .color(Color::Muted)
5348 .size(LabelSize::Small)
5349 .with_animation(
5350 "edit-label",
5351 Animation::new(Duration::from_secs(2))
5352 .repeat()
5353 .with_easing(pulsating_between(0.3, 0.7)),
5354 |label, delta| label.alpha(delta),
5355 ),
5356 )
5357 } else {
5358 let stats = DiffStats::all_files(changed_buffers, cx);
5359 let dot_divider = || {
5360 Label::new("•")
5361 .size(LabelSize::XSmall)
5362 .color(Color::Disabled)
5363 };
5364
5365 this.child(
5366 Label::new("Edits")
5367 .size(LabelSize::Small)
5368 .color(Color::Muted),
5369 )
5370 .child(dot_divider())
5371 .child(
5372 Label::new(format!(
5373 "{} {}",
5374 changed_buffers.len(),
5375 if changed_buffers.len() == 1 {
5376 "file"
5377 } else {
5378 "files"
5379 }
5380 ))
5381 .size(LabelSize::Small)
5382 .color(Color::Muted),
5383 )
5384 .child(dot_divider())
5385 .child(DiffStat::new(
5386 "total",
5387 stats.lines_added as usize,
5388 stats.lines_removed as usize,
5389 ))
5390 }
5391 })
5392 .on_click(cx.listener(|this, _, _, cx| {
5393 this.edits_expanded = !this.edits_expanded;
5394 cx.notify();
5395 })),
5396 )
5397 .when(use_keep_reject_buttons, |this| {
5398 this.child(
5399 h_flex()
5400 .gap_1()
5401 .child(
5402 IconButton::new("review-changes", IconName::ListTodo)
5403 .icon_size(IconSize::Small)
5404 .tooltip({
5405 let focus_handle = focus_handle.clone();
5406 move |_window, cx| {
5407 Tooltip::for_action_in(
5408 "Review Changes",
5409 &OpenAgentDiff,
5410 &focus_handle,
5411 cx,
5412 )
5413 }
5414 })
5415 .on_click(cx.listener(|_, _, window, cx| {
5416 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
5417 })),
5418 )
5419 .child(Divider::vertical().color(DividerColor::Border))
5420 .child(
5421 Button::new("reject-all-changes", "Reject All")
5422 .label_size(LabelSize::Small)
5423 .disabled(pending_edits)
5424 .when(pending_edits, |this| {
5425 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
5426 })
5427 .key_binding(
5428 KeyBinding::for_action_in(
5429 &RejectAll,
5430 &focus_handle.clone(),
5431 cx,
5432 )
5433 .map(|kb| kb.size(rems_from_px(10.))),
5434 )
5435 .on_click(cx.listener(move |this, _, window, cx| {
5436 this.reject_all(&RejectAll, window, cx);
5437 })),
5438 )
5439 .child(
5440 Button::new("keep-all-changes", "Keep All")
5441 .label_size(LabelSize::Small)
5442 .disabled(pending_edits)
5443 .when(pending_edits, |this| {
5444 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
5445 })
5446 .key_binding(
5447 KeyBinding::for_action_in(&KeepAll, &focus_handle, cx)
5448 .map(|kb| kb.size(rems_from_px(10.))),
5449 )
5450 .on_click(cx.listener(move |this, _, window, cx| {
5451 this.keep_all(&KeepAll, window, cx);
5452 })),
5453 ),
5454 )
5455 })
5456 .when(!use_keep_reject_buttons, |this| {
5457 this.child(
5458 Button::new("review-changes", "Review Changes")
5459 .label_size(LabelSize::Small)
5460 .key_binding(
5461 KeyBinding::for_action_in(
5462 &git_ui::project_diff::Diff,
5463 &focus_handle,
5464 cx,
5465 )
5466 .map(|kb| kb.size(rems_from_px(10.))),
5467 )
5468 .on_click(cx.listener(move |_, _, window, cx| {
5469 window.dispatch_action(git_ui::project_diff::Diff.boxed_clone(), cx);
5470 })),
5471 )
5472 })
5473 }
5474
5475 fn render_edited_files_buttons(
5476 &self,
5477 index: usize,
5478 buffer: &Entity<Buffer>,
5479 action_log: &Entity<ActionLog>,
5480 telemetry: &ActionLogTelemetry,
5481 pending_edits: bool,
5482 use_keep_reject_buttons: bool,
5483 editor_bg_color: Hsla,
5484 cx: &Context<Self>,
5485 ) -> impl IntoElement {
5486 let container = h_flex()
5487 .id("edited-buttons-container")
5488 .visible_on_hover("edited-code")
5489 .absolute()
5490 .right_0()
5491 .px_1()
5492 .gap_1()
5493 .bg(editor_bg_color)
5494 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
5495 if *is_hovered {
5496 this.hovered_edited_file_buttons = Some(index);
5497 } else if this.hovered_edited_file_buttons == Some(index) {
5498 this.hovered_edited_file_buttons = None;
5499 }
5500 cx.notify();
5501 }));
5502
5503 if use_keep_reject_buttons {
5504 container
5505 .child(
5506 Button::new(("review", index), "Review")
5507 .label_size(LabelSize::Small)
5508 .on_click({
5509 let buffer = buffer.clone();
5510 let workspace = self.workspace.clone();
5511 cx.listener(move |_, _, window, cx| {
5512 let Some(workspace) = workspace.upgrade() else {
5513 return;
5514 };
5515 let Some(file) = buffer.read(cx).file() else {
5516 return;
5517 };
5518 let project_path = project::ProjectPath {
5519 worktree_id: file.worktree_id(cx),
5520 path: file.path().clone(),
5521 };
5522 workspace.update(cx, |workspace, cx| {
5523 git_ui::project_diff::ProjectDiff::deploy_at_project_path(
5524 workspace,
5525 project_path,
5526 window,
5527 cx,
5528 );
5529 });
5530 })
5531 }),
5532 )
5533 .child(Divider::vertical().color(DividerColor::BorderVariant))
5534 .child(
5535 Button::new(("reject-file", index), "Reject")
5536 .label_size(LabelSize::Small)
5537 .disabled(pending_edits)
5538 .on_click({
5539 let buffer = buffer.clone();
5540 let action_log = action_log.clone();
5541 let telemetry = telemetry.clone();
5542 move |_, _, cx| {
5543 action_log.update(cx, |action_log, cx| {
5544 action_log
5545 .reject_edits_in_ranges(
5546 buffer.clone(),
5547 vec![Anchor::min_max_range_for_buffer(
5548 buffer.read(cx).remote_id(),
5549 )],
5550 Some(telemetry.clone()),
5551 cx,
5552 )
5553 .detach_and_log_err(cx);
5554 })
5555 }
5556 }),
5557 )
5558 .child(
5559 Button::new(("keep-file", index), "Keep")
5560 .label_size(LabelSize::Small)
5561 .disabled(pending_edits)
5562 .on_click({
5563 let buffer = buffer.clone();
5564 let action_log = action_log.clone();
5565 let telemetry = telemetry.clone();
5566 move |_, _, cx| {
5567 action_log.update(cx, |action_log, cx| {
5568 action_log.keep_edits_in_range(
5569 buffer.clone(),
5570 Anchor::min_max_range_for_buffer(
5571 buffer.read(cx).remote_id(),
5572 ),
5573 Some(telemetry.clone()),
5574 cx,
5575 );
5576 })
5577 }
5578 }),
5579 )
5580 .into_any_element()
5581 } else {
5582 container
5583 .child(
5584 Button::new(("review", index), "Review")
5585 .label_size(LabelSize::Small)
5586 .on_click({
5587 let buffer = buffer.clone();
5588 let workspace = self.workspace.clone();
5589 cx.listener(move |_, _, window, cx| {
5590 let Some(workspace) = workspace.upgrade() else {
5591 return;
5592 };
5593 let Some(file) = buffer.read(cx).file() else {
5594 return;
5595 };
5596 let project_path = project::ProjectPath {
5597 worktree_id: file.worktree_id(cx),
5598 path: file.path().clone(),
5599 };
5600 workspace.update(cx, |workspace, cx| {
5601 git_ui::project_diff::ProjectDiff::deploy_at_project_path(
5602 workspace,
5603 project_path,
5604 window,
5605 cx,
5606 );
5607 });
5608 })
5609 }),
5610 )
5611 .into_any_element()
5612 }
5613 }
5614
5615 fn render_edited_files(
5616 &self,
5617 action_log: &Entity<ActionLog>,
5618 telemetry: ActionLogTelemetry,
5619 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
5620 pending_edits: bool,
5621 use_keep_reject_buttons: bool,
5622 cx: &Context<Self>,
5623 ) -> impl IntoElement {
5624 let editor_bg_color = cx.theme().colors().editor_background;
5625
5626 v_flex()
5627 .id("edited_files_list")
5628 .max_h_40()
5629 .overflow_y_scroll()
5630 .children(
5631 changed_buffers
5632 .iter()
5633 .enumerate()
5634 .flat_map(|(index, (buffer, diff))| {
5635 let file = buffer.read(cx).file()?;
5636 let path = file.path();
5637 let path_style = file.path_style(cx);
5638 let separator = file.path_style(cx).primary_separator();
5639
5640 let file_path = path.parent().and_then(|parent| {
5641 if parent.is_empty() {
5642 None
5643 } else {
5644 Some(
5645 Label::new(format!(
5646 "{}{separator}",
5647 parent.display(path_style)
5648 ))
5649 .color(Color::Muted)
5650 .size(LabelSize::XSmall)
5651 .buffer_font(cx),
5652 )
5653 }
5654 });
5655
5656 let file_name = path.file_name().map(|name| {
5657 Label::new(name.to_string())
5658 .size(LabelSize::XSmall)
5659 .buffer_font(cx)
5660 .ml_1()
5661 });
5662
5663 let full_path = path.display(path_style).to_string();
5664
5665 let file_icon = FileIcons::get_icon(path.as_std_path(), cx)
5666 .map(Icon::from_path)
5667 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
5668 .unwrap_or_else(|| {
5669 Icon::new(IconName::File)
5670 .color(Color::Muted)
5671 .size(IconSize::Small)
5672 });
5673
5674 let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
5675
5676 let buttons = self.render_edited_files_buttons(
5677 index,
5678 buffer,
5679 action_log,
5680 &telemetry,
5681 pending_edits,
5682 use_keep_reject_buttons,
5683 editor_bg_color,
5684 cx,
5685 );
5686
5687 let element = h_flex()
5688 .group("edited-code")
5689 .id(("file-container", index))
5690 .relative()
5691 .min_w_0()
5692 .p_1p5()
5693 .gap_2()
5694 .justify_between()
5695 .bg(editor_bg_color)
5696 .when(index < changed_buffers.len() - 1, |parent| {
5697 parent.border_color(cx.theme().colors().border).border_b_1()
5698 })
5699 .child(
5700 h_flex()
5701 .id(("file-name-path", index))
5702 .cursor_pointer()
5703 .pr_0p5()
5704 .gap_0p5()
5705 .rounded_xs()
5706 .child(file_icon)
5707 .children(file_name)
5708 .children(file_path)
5709 .child(
5710 DiffStat::new(
5711 "file",
5712 file_stats.lines_added as usize,
5713 file_stats.lines_removed as usize,
5714 )
5715 .label_size(LabelSize::XSmall),
5716 )
5717 .when(
5718 self.hovered_edited_file_buttons != Some(index),
5719 |this| {
5720 let full_path = full_path.clone();
5721 this.hover(|s| s.bg(cx.theme().colors().element_hover))
5722 .tooltip(move |_, cx| {
5723 Tooltip::with_meta(
5724 "Go to File",
5725 None,
5726 full_path.clone(),
5727 cx,
5728 )
5729 })
5730 .on_click({
5731 let buffer = buffer.clone();
5732 cx.listener(move |this, _, window, cx| {
5733 this.open_edited_buffer(
5734 &buffer, window, cx,
5735 );
5736 })
5737 })
5738 },
5739 ),
5740 )
5741 .child(buttons);
5742
5743 Some(element)
5744 }),
5745 )
5746 .into_any_element()
5747 }
5748
5749 fn render_message_queue_summary(
5750 &self,
5751 _window: &mut Window,
5752 cx: &Context<Self>,
5753 ) -> impl IntoElement {
5754 let queue_count = self.message_queue.len();
5755 let title: SharedString = if queue_count == 1 {
5756 "1 Queued Message".into()
5757 } else {
5758 format!("{} Queued Messages", queue_count).into()
5759 };
5760
5761 h_flex()
5762 .p_1()
5763 .w_full()
5764 .gap_1()
5765 .justify_between()
5766 .when(self.queue_expanded, |this| {
5767 this.border_b_1().border_color(cx.theme().colors().border)
5768 })
5769 .child(
5770 h_flex()
5771 .id("queue_summary")
5772 .gap_1()
5773 .child(Disclosure::new("queue_disclosure", self.queue_expanded))
5774 .child(Label::new(title).size(LabelSize::Small).color(Color::Muted))
5775 .on_click(cx.listener(|this, _, _, cx| {
5776 this.queue_expanded = !this.queue_expanded;
5777 cx.notify();
5778 })),
5779 )
5780 .child(
5781 Button::new("clear_queue", "Clear All")
5782 .label_size(LabelSize::Small)
5783 .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx))
5784 .on_click(cx.listener(|this, _, _, cx| {
5785 this.message_queue.clear();
5786 cx.notify();
5787 })),
5788 )
5789 }
5790
5791 fn render_message_queue_entries(
5792 &self,
5793 _window: &mut Window,
5794 cx: &Context<Self>,
5795 ) -> impl IntoElement {
5796 let message_editor = self.message_editor.read(cx);
5797 let focus_handle = message_editor.focus_handle(cx);
5798
5799 v_flex()
5800 .id("message_queue_list")
5801 .max_h_40()
5802 .overflow_y_scroll()
5803 .children(
5804 self.message_queue
5805 .iter()
5806 .enumerate()
5807 .map(|(index, queued)| {
5808 let is_next = index == 0;
5809 let icon_color = if is_next { Color::Accent } else { Color::Muted };
5810 let queue_len = self.message_queue.len();
5811
5812 let preview = queued
5813 .content
5814 .iter()
5815 .find_map(|block| match block {
5816 acp::ContentBlock::Text(text) => {
5817 text.text.lines().next().map(str::to_owned)
5818 }
5819 _ => None,
5820 })
5821 .unwrap_or_default();
5822
5823 h_flex()
5824 .group("queue_entry")
5825 .w_full()
5826 .p_1()
5827 .pl_2()
5828 .gap_1()
5829 .justify_between()
5830 .bg(cx.theme().colors().editor_background)
5831 .when(index < queue_len - 1, |parent| {
5832 parent.border_color(cx.theme().colors().border).border_b_1()
5833 })
5834 .child(
5835 h_flex()
5836 .id(("queued_prompt", index))
5837 .min_w_0()
5838 .w_full()
5839 .gap_1p5()
5840 .child(
5841 Icon::new(IconName::Circle)
5842 .size(IconSize::Small)
5843 .color(icon_color),
5844 )
5845 .child(
5846 Label::new(preview)
5847 .size(LabelSize::XSmall)
5848 .color(Color::Muted)
5849 .buffer_font(cx)
5850 .truncate(),
5851 )
5852 .when(is_next, |this| {
5853 this.tooltip(Tooltip::text("Next Prompt in the Queue"))
5854 }),
5855 )
5856 .child(
5857 h_flex()
5858 .flex_none()
5859 .gap_1()
5860 .visible_on_hover("queue_entry")
5861 .child(
5862 Button::new(("delete", index), "Remove")
5863 .label_size(LabelSize::Small)
5864 .on_click(cx.listener(move |this, _, _, cx| {
5865 if index < this.message_queue.len() {
5866 this.message_queue.remove(index);
5867 cx.notify();
5868 }
5869 })),
5870 )
5871 .child(
5872 Button::new(("send_now", index), "Send Now")
5873 .style(ButtonStyle::Outlined)
5874 .label_size(LabelSize::Small)
5875 .when(is_next, |this| {
5876 this.key_binding(
5877 KeyBinding::for_action_in(
5878 &SendNextQueuedMessage,
5879 &focus_handle.clone(),
5880 cx,
5881 )
5882 .map(|kb| kb.size(rems_from_px(10.))),
5883 )
5884 })
5885 .on_click(cx.listener(move |this, _, window, cx| {
5886 this.send_queued_message_at_index(
5887 index, true, window, cx,
5888 );
5889 })),
5890 ),
5891 )
5892 }),
5893 )
5894 .into_any_element()
5895 }
5896
5897 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
5898 let focus_handle = self.message_editor.focus_handle(cx);
5899 let editor_bg_color = cx.theme().colors().editor_background;
5900 let (expand_icon, expand_tooltip) = if self.editor_expanded {
5901 (IconName::Minimize, "Minimize Message Editor")
5902 } else {
5903 (IconName::Maximize, "Expand Message Editor")
5904 };
5905
5906 let backdrop = div()
5907 .size_full()
5908 .absolute()
5909 .inset_0()
5910 .bg(cx.theme().colors().panel_background)
5911 .opacity(0.8)
5912 .block_mouse_except_scroll();
5913
5914 let enable_editor = match self.thread_state {
5915 ThreadState::Ready { .. } => true,
5916 ThreadState::Loading { .. }
5917 | ThreadState::Unauthenticated { .. }
5918 | ThreadState::LoadError(..) => false,
5919 };
5920
5921 v_flex()
5922 .on_action(cx.listener(Self::expand_message_editor))
5923 .p_2()
5924 .gap_2()
5925 .border_t_1()
5926 .border_color(cx.theme().colors().border)
5927 .bg(editor_bg_color)
5928 .when(self.editor_expanded, |this| {
5929 this.h(vh(0.8, window)).size_full().justify_between()
5930 })
5931 .child(
5932 v_flex()
5933 .relative()
5934 .size_full()
5935 .pt_1()
5936 .pr_2p5()
5937 .child(self.message_editor.clone())
5938 .child(
5939 h_flex()
5940 .absolute()
5941 .top_0()
5942 .right_0()
5943 .opacity(0.5)
5944 .hover(|this| this.opacity(1.0))
5945 .child(
5946 IconButton::new("toggle-height", expand_icon)
5947 .icon_size(IconSize::Small)
5948 .icon_color(Color::Muted)
5949 .tooltip({
5950 move |_window, cx| {
5951 Tooltip::for_action_in(
5952 expand_tooltip,
5953 &ExpandMessageEditor,
5954 &focus_handle,
5955 cx,
5956 )
5957 }
5958 })
5959 .on_click(cx.listener(|this, _, window, cx| {
5960 this.expand_message_editor(
5961 &ExpandMessageEditor,
5962 window,
5963 cx,
5964 );
5965 })),
5966 ),
5967 ),
5968 )
5969 .child(
5970 h_flex()
5971 .flex_none()
5972 .flex_wrap()
5973 .justify_between()
5974 .child(
5975 h_flex()
5976 .gap_0p5()
5977 .child(self.render_add_context_button(cx))
5978 .child(self.render_follow_toggle(cx)),
5979 )
5980 .child(
5981 h_flex()
5982 .gap_1()
5983 .children(self.render_token_usage(cx))
5984 .children(self.profile_selector.clone())
5985 // Either config_options_view OR (mode_selector + model_selector)
5986 .children(self.config_options_view.clone())
5987 .when(self.config_options_view.is_none(), |this| {
5988 this.children(self.mode_selector().cloned())
5989 .children(self.model_selector.clone())
5990 })
5991 .child(self.render_send_button(cx)),
5992 ),
5993 )
5994 .when(!enable_editor, |this| this.child(backdrop))
5995 .into_any()
5996 }
5997
5998 pub(crate) fn as_native_connection(
5999 &self,
6000 cx: &App,
6001 ) -> Option<Rc<agent::NativeAgentConnection>> {
6002 let acp_thread = self.thread()?.read(cx);
6003 acp_thread.connection().clone().downcast()
6004 }
6005
6006 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
6007 let acp_thread = self.thread()?.read(cx);
6008 self.as_native_connection(cx)?
6009 .thread(acp_thread.session_id(), cx)
6010 }
6011
6012 fn is_imported_thread(&self, cx: &App) -> bool {
6013 let Some(thread) = self.as_native_thread(cx) else {
6014 return false;
6015 };
6016 thread.read(cx).is_imported()
6017 }
6018
6019 fn supports_split_token_display(&self, cx: &App) -> bool {
6020 self.as_native_thread(cx)
6021 .and_then(|thread| thread.read(cx).model())
6022 .is_some_and(|model| model.supports_split_token_display())
6023 }
6024
6025 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<Div> {
6026 let thread = self.thread()?.read(cx);
6027 let usage = thread.token_usage()?;
6028 let is_generating = thread.status() != ThreadStatus::Idle;
6029 let show_split = self.supports_split_token_display(cx);
6030
6031 let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5));
6032 let token_label = |text: String, animation_id: &'static str| {
6033 Label::new(text)
6034 .size(LabelSize::Small)
6035 .color(Color::Muted)
6036 .map(|label| {
6037 if is_generating {
6038 label
6039 .with_animation(
6040 animation_id,
6041 Animation::new(Duration::from_secs(2))
6042 .repeat()
6043 .with_easing(pulsating_between(0.3, 0.8)),
6044 |label, delta| label.alpha(delta),
6045 )
6046 .into_any()
6047 } else {
6048 label.into_any_element()
6049 }
6050 })
6051 };
6052
6053 if show_split {
6054 let max_output_tokens = self
6055 .as_native_thread(cx)
6056 .and_then(|thread| thread.read(cx).model())
6057 .and_then(|model| model.max_output_tokens())
6058 .unwrap_or(0);
6059
6060 let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens);
6061 let input_max = crate::text_thread_editor::humanize_token_count(
6062 usage.max_tokens.saturating_sub(max_output_tokens),
6063 );
6064 let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens);
6065 let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens);
6066
6067 Some(
6068 h_flex()
6069 .flex_shrink_0()
6070 .gap_1()
6071 .mr_1p5()
6072 .child(
6073 h_flex()
6074 .gap_0p5()
6075 .child(
6076 Icon::new(IconName::ArrowUp)
6077 .size(IconSize::XSmall)
6078 .color(Color::Muted),
6079 )
6080 .child(token_label(input, "input-tokens-label"))
6081 .child(
6082 Label::new("/")
6083 .size(LabelSize::Small)
6084 .color(separator_color),
6085 )
6086 .child(
6087 Label::new(input_max)
6088 .size(LabelSize::Small)
6089 .color(Color::Muted),
6090 ),
6091 )
6092 .child(
6093 h_flex()
6094 .gap_0p5()
6095 .child(
6096 Icon::new(IconName::ArrowDown)
6097 .size(IconSize::XSmall)
6098 .color(Color::Muted),
6099 )
6100 .child(token_label(output, "output-tokens-label"))
6101 .child(
6102 Label::new("/")
6103 .size(LabelSize::Small)
6104 .color(separator_color),
6105 )
6106 .child(
6107 Label::new(output_max)
6108 .size(LabelSize::Small)
6109 .color(Color::Muted),
6110 ),
6111 ),
6112 )
6113 } else {
6114 let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
6115 let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
6116
6117 Some(
6118 h_flex()
6119 .flex_shrink_0()
6120 .gap_0p5()
6121 .mr_1p5()
6122 .child(token_label(used, "used-tokens-label"))
6123 .child(
6124 Label::new("/")
6125 .size(LabelSize::Small)
6126 .color(separator_color),
6127 )
6128 .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
6129 )
6130 }
6131 }
6132
6133 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
6134 let Some(thread) = self.thread() else {
6135 return;
6136 };
6137 let telemetry = ActionLogTelemetry::from(thread.read(cx));
6138 let action_log = thread.read(cx).action_log().clone();
6139 action_log.update(cx, |action_log, cx| {
6140 action_log.keep_all_edits(Some(telemetry), cx)
6141 });
6142 }
6143
6144 fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
6145 let Some(thread) = self.thread() else {
6146 return;
6147 };
6148 let telemetry = ActionLogTelemetry::from(thread.read(cx));
6149 let action_log = thread.read(cx).action_log().clone();
6150 action_log
6151 .update(cx, |action_log, cx| {
6152 action_log.reject_all_edits(Some(telemetry), cx)
6153 })
6154 .detach();
6155 }
6156
6157 fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
6158 self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
6159 }
6160
6161 fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
6162 self.authorize_pending_with_granularity(true, window, cx);
6163 }
6164
6165 fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
6166 self.authorize_pending_with_granularity(false, window, cx);
6167 }
6168
6169 fn authorize_pending_with_granularity(
6170 &mut self,
6171 is_allow: bool,
6172 window: &mut Window,
6173 cx: &mut Context<Self>,
6174 ) -> Option<()> {
6175 let thread = self.thread()?.read(cx);
6176 let tool_call = thread.first_tool_awaiting_confirmation()?;
6177 let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
6178 return None;
6179 };
6180 let tool_call_id = tool_call.id.clone();
6181
6182 // Get granularity options (all options except old deny option)
6183 let granularity_options: Vec<_> = options
6184 .iter()
6185 .filter(|o| {
6186 matches!(
6187 o.kind,
6188 acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
6189 )
6190 })
6191 .collect();
6192
6193 // Get selected index, defaulting to last option ("Only this time")
6194 let selected_index = self
6195 .selected_permission_granularity
6196 .get(&tool_call_id)
6197 .copied()
6198 .unwrap_or_else(|| granularity_options.len().saturating_sub(1));
6199
6200 let selected_option = granularity_options
6201 .get(selected_index)
6202 .or(granularity_options.last())
6203 .copied()?;
6204
6205 let option_id_str = selected_option.option_id.0.to_string();
6206
6207 // Transform option_id based on allow/deny
6208 let (final_option_id, final_option_kind) = if is_allow {
6209 let allow_id = if option_id_str == "once" {
6210 "allow".to_string()
6211 } else if let Some(rest) = option_id_str.strip_prefix("always:") {
6212 format!("always_allow:{}", rest)
6213 } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
6214 format!("always_allow_pattern:{}", rest)
6215 } else {
6216 option_id_str
6217 };
6218 (acp::PermissionOptionId::new(allow_id), selected_option.kind)
6219 } else {
6220 let deny_id = if option_id_str == "once" {
6221 "deny".to_string()
6222 } else if let Some(rest) = option_id_str.strip_prefix("always:") {
6223 format!("always_deny:{}", rest)
6224 } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
6225 format!("always_deny_pattern:{}", rest)
6226 } else {
6227 option_id_str.replace("allow", "deny")
6228 };
6229 let deny_kind = match selected_option.kind {
6230 acp::PermissionOptionKind::AllowOnce => acp::PermissionOptionKind::RejectOnce,
6231 acp::PermissionOptionKind::AllowAlways => acp::PermissionOptionKind::RejectAlways,
6232 other => other,
6233 };
6234 (acp::PermissionOptionId::new(deny_id), deny_kind)
6235 };
6236
6237 self.authorize_tool_call(tool_call_id, final_option_id, final_option_kind, window, cx);
6238
6239 Some(())
6240 }
6241
6242 fn open_permission_dropdown(
6243 &mut self,
6244 _: &crate::OpenPermissionDropdown,
6245 window: &mut Window,
6246 cx: &mut Context<Self>,
6247 ) {
6248 self.permission_dropdown_handle.toggle(window, cx);
6249 }
6250
6251 fn handle_select_permission_granularity(
6252 &mut self,
6253 action: &SelectPermissionGranularity,
6254 _window: &mut Window,
6255 cx: &mut Context<Self>,
6256 ) {
6257 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
6258 self.selected_permission_granularity
6259 .insert(tool_call_id, action.index);
6260 cx.notify();
6261 }
6262
6263 fn handle_authorize_tool_call(
6264 &mut self,
6265 action: &AuthorizeToolCall,
6266 window: &mut Window,
6267 cx: &mut Context<Self>,
6268 ) {
6269 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
6270 let option_id = acp::PermissionOptionId::new(action.option_id.clone());
6271 let option_kind = match action.option_kind.as_str() {
6272 "AllowOnce" => acp::PermissionOptionKind::AllowOnce,
6273 "AllowAlways" => acp::PermissionOptionKind::AllowAlways,
6274 "RejectOnce" => acp::PermissionOptionKind::RejectOnce,
6275 "RejectAlways" => acp::PermissionOptionKind::RejectAlways,
6276 _ => acp::PermissionOptionKind::AllowOnce,
6277 };
6278
6279 self.authorize_tool_call(tool_call_id, option_id, option_kind, window, cx);
6280 }
6281
6282 fn authorize_pending_tool_call(
6283 &mut self,
6284 kind: acp::PermissionOptionKind,
6285 window: &mut Window,
6286 cx: &mut Context<Self>,
6287 ) -> Option<()> {
6288 let thread = self.thread()?.read(cx);
6289 let tool_call = thread.first_tool_awaiting_confirmation()?;
6290 let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
6291 return None;
6292 };
6293 let option = options.iter().find(|o| o.kind == kind)?;
6294
6295 self.authorize_tool_call(
6296 tool_call.id.clone(),
6297 option.option_id.clone(),
6298 option.kind,
6299 window,
6300 cx,
6301 );
6302
6303 Some(())
6304 }
6305
6306 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
6307 let message_editor = self.message_editor.read(cx);
6308 let is_editor_empty = message_editor.is_empty(cx);
6309 let focus_handle = message_editor.focus_handle(cx);
6310
6311 let is_generating = self
6312 .thread()
6313 .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
6314
6315 if self.is_loading_contents {
6316 div()
6317 .id("loading-message-content")
6318 .px_1()
6319 .tooltip(Tooltip::text("Loading Added Context…"))
6320 .child(loading_contents_spinner(IconSize::default()))
6321 .into_any_element()
6322 } else if is_generating && is_editor_empty {
6323 IconButton::new("stop-generation", IconName::Stop)
6324 .icon_color(Color::Error)
6325 .style(ButtonStyle::Tinted(TintColor::Error))
6326 .tooltip(move |_window, cx| {
6327 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx)
6328 })
6329 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
6330 .into_any_element()
6331 } else {
6332 IconButton::new("send-message", IconName::Send)
6333 .style(ButtonStyle::Filled)
6334 .map(|this| {
6335 if is_editor_empty && !is_generating {
6336 this.disabled(true).icon_color(Color::Muted)
6337 } else {
6338 this.icon_color(Color::Accent)
6339 }
6340 })
6341 .tooltip(move |_window, cx| {
6342 if is_editor_empty && !is_generating {
6343 Tooltip::for_action("Type to Send", &Chat, cx)
6344 } else if is_generating {
6345 let focus_handle = focus_handle.clone();
6346
6347 Tooltip::element(move |_window, cx| {
6348 v_flex()
6349 .gap_1()
6350 .child(
6351 h_flex()
6352 .gap_2()
6353 .justify_between()
6354 .child(Label::new("Queue and Send"))
6355 .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)),
6356 )
6357 .child(
6358 h_flex()
6359 .pt_1()
6360 .gap_2()
6361 .justify_between()
6362 .border_t_1()
6363 .border_color(cx.theme().colors().border_variant)
6364 .child(Label::new("Send Immediately"))
6365 .child(KeyBinding::for_action_in(
6366 &SendImmediately,
6367 &focus_handle,
6368 cx,
6369 )),
6370 )
6371 .into_any_element()
6372 })(_window, cx)
6373 } else {
6374 Tooltip::for_action("Send Message", &Chat, cx)
6375 }
6376 })
6377 .on_click(cx.listener(|this, _, window, cx| {
6378 this.send(window, cx);
6379 }))
6380 .into_any_element()
6381 }
6382 }
6383
6384 fn is_following(&self, cx: &App) -> bool {
6385 match self.thread().map(|thread| thread.read(cx).status()) {
6386 Some(ThreadStatus::Generating) => self
6387 .workspace
6388 .read_with(cx, |workspace, _| {
6389 workspace.is_being_followed(CollaboratorId::Agent)
6390 })
6391 .unwrap_or(false),
6392 _ => self.should_be_following,
6393 }
6394 }
6395
6396 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
6397 let following = self.is_following(cx);
6398
6399 self.should_be_following = !following;
6400 if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) {
6401 self.workspace
6402 .update(cx, |workspace, cx| {
6403 if following {
6404 workspace.unfollow(CollaboratorId::Agent, window, cx);
6405 } else {
6406 workspace.follow(CollaboratorId::Agent, window, cx);
6407 }
6408 })
6409 .ok();
6410 }
6411
6412 telemetry::event!("Follow Agent Selected", following = !following);
6413 }
6414
6415 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
6416 let following = self.is_following(cx);
6417
6418 let tooltip_label = if following {
6419 if self.agent.name() == "Zed Agent" {
6420 format!("Stop Following the {}", self.agent.name())
6421 } else {
6422 format!("Stop Following {}", self.agent.name())
6423 }
6424 } else {
6425 if self.agent.name() == "Zed Agent" {
6426 format!("Follow the {}", self.agent.name())
6427 } else {
6428 format!("Follow {}", self.agent.name())
6429 }
6430 };
6431
6432 IconButton::new("follow-agent", IconName::Crosshair)
6433 .icon_size(IconSize::Small)
6434 .icon_color(Color::Muted)
6435 .toggle_state(following)
6436 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
6437 .tooltip(move |_window, cx| {
6438 if following {
6439 Tooltip::for_action(tooltip_label.clone(), &Follow, cx)
6440 } else {
6441 Tooltip::with_meta(
6442 tooltip_label.clone(),
6443 Some(&Follow),
6444 "Track the agent's location as it reads and edits files.",
6445 cx,
6446 )
6447 }
6448 })
6449 .on_click(cx.listener(move |this, _, window, cx| {
6450 this.toggle_following(window, cx);
6451 }))
6452 }
6453
6454 fn render_add_context_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6455 let message_editor = self.message_editor.clone();
6456 let menu_visible = message_editor.read(cx).is_completions_menu_visible(cx);
6457
6458 IconButton::new("add-context", IconName::AtSign)
6459 .icon_size(IconSize::Small)
6460 .icon_color(Color::Muted)
6461 .when(!menu_visible, |this| {
6462 this.tooltip(move |_window, cx| {
6463 Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx)
6464 })
6465 })
6466 .on_click(cx.listener(move |_this, _, window, cx| {
6467 let message_editor_clone = message_editor.clone();
6468
6469 window.defer(cx, move |window, cx| {
6470 message_editor_clone.update(cx, |message_editor, cx| {
6471 message_editor.trigger_completion_menu(window, cx);
6472 });
6473 });
6474 }))
6475 }
6476
6477 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
6478 let workspace = self.workspace.clone();
6479 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
6480 Self::open_link(text, &workspace, window, cx);
6481 })
6482 }
6483
6484 fn open_link(
6485 url: SharedString,
6486 workspace: &WeakEntity<Workspace>,
6487 window: &mut Window,
6488 cx: &mut App,
6489 ) {
6490 let Some(workspace) = workspace.upgrade() else {
6491 cx.open_url(&url);
6492 return;
6493 };
6494
6495 if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err()
6496 {
6497 workspace.update(cx, |workspace, cx| match mention {
6498 MentionUri::File { abs_path } => {
6499 let project = workspace.project();
6500 let Some(path) =
6501 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
6502 else {
6503 return;
6504 };
6505
6506 workspace
6507 .open_path(path, None, true, window, cx)
6508 .detach_and_log_err(cx);
6509 }
6510 MentionUri::PastedImage => {}
6511 MentionUri::Directory { abs_path } => {
6512 let project = workspace.project();
6513 let Some(entry_id) = project.update(cx, |project, cx| {
6514 let path = project.find_project_path(abs_path, cx)?;
6515 project.entry_for_path(&path, cx).map(|entry| entry.id)
6516 }) else {
6517 return;
6518 };
6519
6520 project.update(cx, |_, cx| {
6521 cx.emit(project::Event::RevealInProjectPanel(entry_id));
6522 });
6523 }
6524 MentionUri::Symbol {
6525 abs_path: path,
6526 line_range,
6527 ..
6528 }
6529 | MentionUri::Selection {
6530 abs_path: Some(path),
6531 line_range,
6532 } => {
6533 let project = workspace.project();
6534 let Some(path) =
6535 project.update(cx, |project, cx| project.find_project_path(path, cx))
6536 else {
6537 return;
6538 };
6539
6540 let item = workspace.open_path(path, None, true, window, cx);
6541 window
6542 .spawn(cx, async move |cx| {
6543 let Some(editor) = item.await?.downcast::<Editor>() else {
6544 return Ok(());
6545 };
6546 let range = Point::new(*line_range.start(), 0)
6547 ..Point::new(*line_range.start(), 0);
6548 editor
6549 .update_in(cx, |editor, window, cx| {
6550 editor.change_selections(
6551 SelectionEffects::scroll(Autoscroll::center()),
6552 window,
6553 cx,
6554 |s| s.select_ranges(vec![range]),
6555 );
6556 })
6557 .ok();
6558 anyhow::Ok(())
6559 })
6560 .detach_and_log_err(cx);
6561 }
6562 MentionUri::Selection { abs_path: None, .. } => {}
6563 MentionUri::Thread { id, name } => {
6564 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
6565 panel.update(cx, |panel, cx| {
6566 panel.load_agent_thread(
6567 AgentSessionInfo {
6568 session_id: id,
6569 cwd: None,
6570 title: Some(name.into()),
6571 updated_at: None,
6572 meta: None,
6573 },
6574 window,
6575 cx,
6576 )
6577 });
6578 }
6579 }
6580 MentionUri::TextThread { path, .. } => {
6581 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
6582 panel.update(cx, |panel, cx| {
6583 panel
6584 .open_saved_text_thread(path.as_path().into(), window, cx)
6585 .detach_and_log_err(cx);
6586 });
6587 }
6588 }
6589 MentionUri::Rule { id, .. } => {
6590 let PromptId::User { uuid } = id else {
6591 return;
6592 };
6593 window.dispatch_action(
6594 Box::new(OpenRulesLibrary {
6595 prompt_to_select: Some(uuid.0),
6596 }),
6597 cx,
6598 )
6599 }
6600 MentionUri::Fetch { url } => {
6601 cx.open_url(url.as_str());
6602 }
6603 })
6604 } else {
6605 cx.open_url(&url);
6606 }
6607 }
6608
6609 fn open_tool_call_location(
6610 &self,
6611 entry_ix: usize,
6612 location_ix: usize,
6613 window: &mut Window,
6614 cx: &mut Context<Self>,
6615 ) -> Option<()> {
6616 let (tool_call_location, agent_location) = self
6617 .thread()?
6618 .read(cx)
6619 .entries()
6620 .get(entry_ix)?
6621 .location(location_ix)?;
6622
6623 let project_path = self
6624 .project
6625 .read(cx)
6626 .find_project_path(&tool_call_location.path, cx)?;
6627
6628 let open_task = self
6629 .workspace
6630 .update(cx, |workspace, cx| {
6631 workspace.open_path(project_path, None, true, window, cx)
6632 })
6633 .log_err()?;
6634 window
6635 .spawn(cx, async move |cx| {
6636 let item = open_task.await?;
6637
6638 let Some(active_editor) = item.downcast::<Editor>() else {
6639 return anyhow::Ok(());
6640 };
6641
6642 active_editor.update_in(cx, |editor, window, cx| {
6643 let multibuffer = editor.buffer().read(cx);
6644 let buffer = multibuffer.as_singleton();
6645 if agent_location.buffer.upgrade() == buffer {
6646 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
6647 let anchor =
6648 editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position);
6649 editor.change_selections(Default::default(), window, cx, |selections| {
6650 selections.select_anchor_ranges([anchor..anchor]);
6651 })
6652 } else {
6653 let row = tool_call_location.line.unwrap_or_default();
6654 editor.change_selections(Default::default(), window, cx, |selections| {
6655 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
6656 })
6657 }
6658 })?;
6659
6660 anyhow::Ok(())
6661 })
6662 .detach_and_log_err(cx);
6663
6664 None
6665 }
6666
6667 pub fn open_thread_as_markdown(
6668 &self,
6669 workspace: Entity<Workspace>,
6670 window: &mut Window,
6671 cx: &mut App,
6672 ) -> Task<Result<()>> {
6673 let markdown_language_task = workspace
6674 .read(cx)
6675 .app_state()
6676 .languages
6677 .language_for_name("Markdown");
6678
6679 let (thread_title, markdown) = if let Some(thread) = self.thread() {
6680 let thread = thread.read(cx);
6681 (thread.title().to_string(), thread.to_markdown(cx))
6682 } else {
6683 return Task::ready(Ok(()));
6684 };
6685
6686 let project = workspace.read(cx).project().clone();
6687 window.spawn(cx, async move |cx| {
6688 let markdown_language = markdown_language_task.await?;
6689
6690 let buffer = project
6691 .update(cx, |project, cx| {
6692 project.create_buffer(Some(markdown_language), false, cx)
6693 })
6694 .await?;
6695
6696 buffer.update(cx, |buffer, cx| {
6697 buffer.set_text(markdown, cx);
6698 buffer.set_capability(language::Capability::ReadWrite, cx);
6699 });
6700
6701 workspace.update_in(cx, |workspace, window, cx| {
6702 let buffer = cx
6703 .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone()));
6704
6705 workspace.add_item_to_active_pane(
6706 Box::new(cx.new(|cx| {
6707 let mut editor =
6708 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
6709 editor.set_breadcrumb_header(thread_title);
6710 editor
6711 })),
6712 None,
6713 true,
6714 window,
6715 cx,
6716 );
6717 })?;
6718 anyhow::Ok(())
6719 })
6720 }
6721
6722 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
6723 self.list_state.scroll_to(ListOffset::default());
6724 cx.notify();
6725 }
6726
6727 fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context<Self>) {
6728 let Some(thread) = self.thread() else {
6729 return;
6730 };
6731
6732 let entries = thread.read(cx).entries();
6733 if entries.is_empty() {
6734 return;
6735 }
6736
6737 // Find the most recent user message and scroll it to the top of the viewport.
6738 // (Fallback: if no user message exists, scroll to the bottom.)
6739 if let Some(ix) = entries
6740 .iter()
6741 .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
6742 {
6743 self.list_state.scroll_to(ListOffset {
6744 item_ix: ix,
6745 offset_in_item: px(0.0),
6746 });
6747 cx.notify();
6748 } else {
6749 self.scroll_to_bottom(cx);
6750 }
6751 }
6752
6753 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
6754 if let Some(thread) = self.thread() {
6755 let entry_count = thread.read(cx).entries().len();
6756 self.list_state.reset(entry_count);
6757 cx.notify();
6758 }
6759 }
6760
6761 fn notify_with_sound(
6762 &mut self,
6763 caption: impl Into<SharedString>,
6764 icon: IconName,
6765 window: &mut Window,
6766 cx: &mut Context<Self>,
6767 ) {
6768 self.play_notification_sound(window, cx);
6769 self.show_notification(caption, icon, window, cx);
6770 }
6771
6772 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
6773 let settings = AgentSettings::get_global(cx);
6774 if settings.play_sound_when_agent_done && !window.is_window_active() {
6775 Audio::play_sound(Sound::AgentDone, cx);
6776 }
6777 }
6778
6779 fn show_notification(
6780 &mut self,
6781 caption: impl Into<SharedString>,
6782 icon: IconName,
6783 window: &mut Window,
6784 cx: &mut Context<Self>,
6785 ) {
6786 if !self.notifications.is_empty() {
6787 return;
6788 }
6789
6790 let settings = AgentSettings::get_global(cx);
6791
6792 let window_is_inactive = !window.is_window_active();
6793 let panel_is_hidden = self
6794 .workspace
6795 .upgrade()
6796 .map(|workspace| AgentPanel::is_hidden(&workspace, cx))
6797 .unwrap_or(true);
6798
6799 let should_notify = window_is_inactive || panel_is_hidden;
6800
6801 if !should_notify {
6802 return;
6803 }
6804
6805 // TODO: Change this once we have title summarization for external agents.
6806 let title = self.agent.name();
6807
6808 match settings.notify_when_agent_waiting {
6809 NotifyWhenAgentWaiting::PrimaryScreen => {
6810 if let Some(primary) = cx.primary_display() {
6811 self.pop_up(icon, caption.into(), title, window, primary, cx);
6812 }
6813 }
6814 NotifyWhenAgentWaiting::AllScreens => {
6815 let caption = caption.into();
6816 for screen in cx.displays() {
6817 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
6818 }
6819 }
6820 NotifyWhenAgentWaiting::Never => {
6821 // Don't show anything
6822 }
6823 }
6824 }
6825
6826 fn pop_up(
6827 &mut self,
6828 icon: IconName,
6829 caption: SharedString,
6830 title: SharedString,
6831 window: &mut Window,
6832 screen: Rc<dyn PlatformDisplay>,
6833 cx: &mut Context<Self>,
6834 ) {
6835 let options = AgentNotification::window_options(screen, cx);
6836
6837 let project_name = self.workspace.upgrade().and_then(|workspace| {
6838 workspace
6839 .read(cx)
6840 .project()
6841 .read(cx)
6842 .visible_worktrees(cx)
6843 .next()
6844 .map(|worktree| worktree.read(cx).root_name_str().to_string())
6845 });
6846
6847 if let Some(screen_window) = cx
6848 .open_window(options, |_window, cx| {
6849 cx.new(|_cx| {
6850 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
6851 })
6852 })
6853 .log_err()
6854 && let Some(pop_up) = screen_window.entity(cx).log_err()
6855 {
6856 self.notification_subscriptions
6857 .entry(screen_window)
6858 .or_insert_with(Vec::new)
6859 .push(cx.subscribe_in(&pop_up, window, {
6860 |this, _, event, window, cx| match event {
6861 AgentNotificationEvent::Accepted => {
6862 let handle = window.window_handle();
6863 cx.activate(true);
6864
6865 let workspace_handle = this.workspace.clone();
6866
6867 // If there are multiple Zed windows, activate the correct one.
6868 cx.defer(move |cx| {
6869 handle
6870 .update(cx, |_view, window, _cx| {
6871 window.activate_window();
6872
6873 if let Some(workspace) = workspace_handle.upgrade() {
6874 workspace.update(_cx, |workspace, cx| {
6875 workspace.focus_panel::<AgentPanel>(window, cx);
6876 });
6877 }
6878 })
6879 .log_err();
6880 });
6881
6882 this.dismiss_notifications(cx);
6883 }
6884 AgentNotificationEvent::Dismissed => {
6885 this.dismiss_notifications(cx);
6886 }
6887 }
6888 }));
6889
6890 self.notifications.push(screen_window);
6891
6892 // If the user manually refocuses the original window, dismiss the popup.
6893 self.notification_subscriptions
6894 .entry(screen_window)
6895 .or_insert_with(Vec::new)
6896 .push({
6897 let pop_up_weak = pop_up.downgrade();
6898
6899 cx.observe_window_activation(window, move |_, window, cx| {
6900 if window.is_window_active()
6901 && let Some(pop_up) = pop_up_weak.upgrade()
6902 {
6903 pop_up.update(cx, |_, cx| {
6904 cx.emit(AgentNotificationEvent::Dismissed);
6905 });
6906 }
6907 })
6908 });
6909 }
6910 }
6911
6912 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
6913 for window in self.notifications.drain(..) {
6914 window
6915 .update(cx, |_, window, _| {
6916 window.remove_window();
6917 })
6918 .ok();
6919
6920 self.notification_subscriptions.remove(&window);
6921 }
6922 }
6923
6924 fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
6925 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
6926 let elapsed_label = show_stats
6927 .then(|| {
6928 self.turn_started_at.and_then(|started_at| {
6929 let elapsed = started_at.elapsed();
6930 (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed))
6931 })
6932 })
6933 .flatten();
6934
6935 let is_waiting = confirmation
6936 || self
6937 .thread()
6938 .is_some_and(|thread| thread.read(cx).has_in_progress_tool_calls());
6939
6940 let turn_tokens_label = elapsed_label
6941 .is_some()
6942 .then(|| {
6943 self.turn_tokens
6944 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
6945 .map(|tokens| crate::text_thread_editor::humanize_token_count(tokens))
6946 })
6947 .flatten();
6948
6949 let arrow_icon = if is_waiting {
6950 IconName::ArrowUp
6951 } else {
6952 IconName::ArrowDown
6953 };
6954
6955 h_flex()
6956 .id("generating-spinner")
6957 .py_2()
6958 .px(rems_from_px(22.))
6959 .gap_2()
6960 .map(|this| {
6961 if confirmation {
6962 this.child(
6963 h_flex()
6964 .w_2()
6965 .child(SpinnerLabel::sand().size(LabelSize::Small)),
6966 )
6967 .child(
6968 div().min_w(rems(8.)).child(
6969 LoadingLabel::new("Waiting Confirmation")
6970 .size(LabelSize::Small)
6971 .color(Color::Muted),
6972 ),
6973 )
6974 } else {
6975 this.child(SpinnerLabel::new().size(LabelSize::Small))
6976 }
6977 })
6978 .when_some(elapsed_label, |this, elapsed| {
6979 this.child(
6980 Label::new(elapsed)
6981 .size(LabelSize::Small)
6982 .color(Color::Muted),
6983 )
6984 })
6985 .when_some(turn_tokens_label, |this, tokens| {
6986 this.child(
6987 h_flex()
6988 .gap_0p5()
6989 .child(
6990 Icon::new(arrow_icon)
6991 .size(IconSize::XSmall)
6992 .color(Color::Muted),
6993 )
6994 .child(
6995 Label::new(format!("{} tokens", tokens))
6996 .size(LabelSize::Small)
6997 .color(Color::Muted),
6998 ),
6999 )
7000 })
7001 .into_any_element()
7002 }
7003
7004 fn render_thread_controls(
7005 &self,
7006 thread: &Entity<AcpThread>,
7007 cx: &Context<Self>,
7008 ) -> impl IntoElement {
7009 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
7010 if is_generating {
7011 return self.render_generating(false, cx).into_any_element();
7012 }
7013
7014 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
7015 .shape(ui::IconButtonShape::Square)
7016 .icon_size(IconSize::Small)
7017 .icon_color(Color::Ignored)
7018 .tooltip(Tooltip::text("Open Thread as Markdown"))
7019 .on_click(cx.listener(move |this, _, window, cx| {
7020 if let Some(workspace) = this.workspace.upgrade() {
7021 this.open_thread_as_markdown(workspace, window, cx)
7022 .detach_and_log_err(cx);
7023 }
7024 }));
7025
7026 let scroll_to_recent_user_prompt =
7027 IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow)
7028 .shape(ui::IconButtonShape::Square)
7029 .icon_size(IconSize::Small)
7030 .icon_color(Color::Ignored)
7031 .tooltip(Tooltip::text("Scroll To Most Recent User Prompt"))
7032 .on_click(cx.listener(move |this, _, _, cx| {
7033 this.scroll_to_most_recent_user_prompt(cx);
7034 }));
7035
7036 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
7037 .shape(ui::IconButtonShape::Square)
7038 .icon_size(IconSize::Small)
7039 .icon_color(Color::Ignored)
7040 .tooltip(Tooltip::text("Scroll To Top"))
7041 .on_click(cx.listener(move |this, _, _, cx| {
7042 this.scroll_to_top(cx);
7043 }));
7044
7045 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
7046 let last_turn_clock = show_stats
7047 .then(|| {
7048 self.last_turn_duration
7049 .filter(|&duration| duration > STOPWATCH_THRESHOLD)
7050 .map(|duration| {
7051 Label::new(duration_alt_display(duration))
7052 .size(LabelSize::Small)
7053 .color(Color::Muted)
7054 })
7055 })
7056 .flatten();
7057
7058 let last_turn_tokens = last_turn_clock
7059 .is_some()
7060 .then(|| {
7061 self.last_turn_tokens
7062 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
7063 .map(|tokens| {
7064 Label::new(format!(
7065 "{} tokens",
7066 crate::text_thread_editor::humanize_token_count(tokens)
7067 ))
7068 .size(LabelSize::Small)
7069 .color(Color::Muted)
7070 })
7071 })
7072 .flatten();
7073
7074 let mut container = h_flex()
7075 .w_full()
7076 .py_2()
7077 .px_5()
7078 .gap_px()
7079 .opacity(0.6)
7080 .hover(|s| s.opacity(1.))
7081 .justify_end()
7082 .when(
7083 last_turn_tokens.is_some() || last_turn_clock.is_some(),
7084 |this| {
7085 this.child(
7086 h_flex()
7087 .gap_1()
7088 .px_1()
7089 .when_some(last_turn_tokens, |this, label| this.child(label))
7090 .when_some(last_turn_clock, |this, label| this.child(label)),
7091 )
7092 },
7093 );
7094
7095 if AgentSettings::get_global(cx).enable_feedback
7096 && self
7097 .thread()
7098 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
7099 {
7100 let feedback = self.thread_feedback.feedback;
7101
7102 let tooltip_meta = || {
7103 SharedString::new(
7104 "Rating the thread sends all of your current conversation to the Zed team.",
7105 )
7106 };
7107
7108 container = container
7109 .child(
7110 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
7111 .shape(ui::IconButtonShape::Square)
7112 .icon_size(IconSize::Small)
7113 .icon_color(match feedback {
7114 Some(ThreadFeedback::Positive) => Color::Accent,
7115 _ => Color::Ignored,
7116 })
7117 .tooltip(move |window, cx| match feedback {
7118 Some(ThreadFeedback::Positive) => {
7119 Tooltip::text("Thanks for your feedback!")(window, cx)
7120 }
7121 _ => Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx),
7122 })
7123 .on_click(cx.listener(move |this, _, window, cx| {
7124 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
7125 })),
7126 )
7127 .child(
7128 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
7129 .shape(ui::IconButtonShape::Square)
7130 .icon_size(IconSize::Small)
7131 .icon_color(match feedback {
7132 Some(ThreadFeedback::Negative) => Color::Accent,
7133 _ => Color::Ignored,
7134 })
7135 .tooltip(move |window, cx| match feedback {
7136 Some(ThreadFeedback::Negative) => {
7137 Tooltip::text(
7138 "We appreciate your feedback and will use it to improve in the future.",
7139 )(window, cx)
7140 }
7141 _ => {
7142 Tooltip::with_meta("Not Helpful Response", None, tooltip_meta(), cx)
7143 }
7144 })
7145 .on_click(cx.listener(move |this, _, window, cx| {
7146 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
7147 })),
7148 );
7149 }
7150
7151 if cx.has_flag::<AgentSharingFeatureFlag>()
7152 && self.is_imported_thread(cx)
7153 && self
7154 .project
7155 .read(cx)
7156 .client()
7157 .status()
7158 .borrow()
7159 .is_connected()
7160 {
7161 let sync_button = IconButton::new("sync-thread", IconName::ArrowCircle)
7162 .shape(ui::IconButtonShape::Square)
7163 .icon_size(IconSize::Small)
7164 .icon_color(Color::Ignored)
7165 .tooltip(Tooltip::text("Sync with source thread"))
7166 .on_click(cx.listener(move |this, _, window, cx| {
7167 this.sync_thread(window, cx);
7168 }));
7169
7170 container = container.child(sync_button);
7171 }
7172
7173 if cx.has_flag::<AgentSharingFeatureFlag>() && !self.is_imported_thread(cx) {
7174 let share_button = IconButton::new("share-thread", IconName::ArrowUpRight)
7175 .shape(ui::IconButtonShape::Square)
7176 .icon_size(IconSize::Small)
7177 .icon_color(Color::Ignored)
7178 .tooltip(Tooltip::text("Share Thread"))
7179 .on_click(cx.listener(move |this, _, window, cx| {
7180 this.share_thread(window, cx);
7181 }));
7182
7183 container = container.child(share_button);
7184 }
7185
7186 container
7187 .child(open_as_markdown)
7188 .child(scroll_to_recent_user_prompt)
7189 .child(scroll_to_top)
7190 .into_any_element()
7191 }
7192
7193 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
7194 h_flex()
7195 .key_context("AgentFeedbackMessageEditor")
7196 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
7197 this.thread_feedback.dismiss_comments();
7198 cx.notify();
7199 }))
7200 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
7201 this.submit_feedback_message(cx);
7202 }))
7203 .p_2()
7204 .mb_2()
7205 .mx_5()
7206 .gap_1()
7207 .rounded_md()
7208 .border_1()
7209 .border_color(cx.theme().colors().border)
7210 .bg(cx.theme().colors().editor_background)
7211 .child(div().w_full().child(editor))
7212 .child(
7213 h_flex()
7214 .child(
7215 IconButton::new("dismiss-feedback-message", IconName::Close)
7216 .icon_color(Color::Error)
7217 .icon_size(IconSize::XSmall)
7218 .shape(ui::IconButtonShape::Square)
7219 .on_click(cx.listener(move |this, _, _window, cx| {
7220 this.thread_feedback.dismiss_comments();
7221 cx.notify();
7222 })),
7223 )
7224 .child(
7225 IconButton::new("submit-feedback-message", IconName::Return)
7226 .icon_size(IconSize::XSmall)
7227 .shape(ui::IconButtonShape::Square)
7228 .on_click(cx.listener(move |this, _, _window, cx| {
7229 this.submit_feedback_message(cx);
7230 })),
7231 ),
7232 )
7233 }
7234
7235 fn handle_feedback_click(
7236 &mut self,
7237 feedback: ThreadFeedback,
7238 window: &mut Window,
7239 cx: &mut Context<Self>,
7240 ) {
7241 let Some(thread) = self.thread().cloned() else {
7242 return;
7243 };
7244
7245 self.thread_feedback.submit(thread, feedback, window, cx);
7246 cx.notify();
7247 }
7248
7249 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
7250 let Some(thread) = self.thread().cloned() else {
7251 return;
7252 };
7253
7254 self.thread_feedback.submit_comments(thread, cx);
7255 cx.notify();
7256 }
7257
7258 fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
7259 if self.token_limit_callout_dismissed {
7260 return None;
7261 }
7262
7263 let token_usage = self.thread()?.read(cx).token_usage()?;
7264 let ratio = token_usage.ratio();
7265
7266 let (severity, icon, title) = match ratio {
7267 acp_thread::TokenUsageRatio::Normal => return None,
7268 acp_thread::TokenUsageRatio::Warning => (
7269 Severity::Warning,
7270 IconName::Warning,
7271 "Thread reaching the token limit soon",
7272 ),
7273 acp_thread::TokenUsageRatio::Exceeded => (
7274 Severity::Error,
7275 IconName::XCircle,
7276 "Thread reached the token limit",
7277 ),
7278 };
7279
7280 let description = "To continue, start a new thread from a summary.";
7281
7282 Some(
7283 Callout::new()
7284 .severity(severity)
7285 .icon(icon)
7286 .title(title)
7287 .description(description)
7288 .actions_slot(
7289 h_flex().gap_0p5().child(
7290 Button::new("start-new-thread", "Start New Thread")
7291 .label_size(LabelSize::Small)
7292 .on_click(cx.listener(|this, _, window, cx| {
7293 let Some(thread) = this.thread() else {
7294 return;
7295 };
7296 let session_id = thread.read(cx).session_id().clone();
7297 window.dispatch_action(
7298 crate::NewNativeAgentThreadFromSummary {
7299 from_session_id: session_id,
7300 }
7301 .boxed_clone(),
7302 cx,
7303 );
7304 })),
7305 ),
7306 )
7307 .dismiss_action(self.dismiss_error_button(cx)),
7308 )
7309 }
7310
7311 fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
7312 self.entry_view_state.update(cx, |entry_view_state, cx| {
7313 entry_view_state.agent_ui_font_size_changed(cx);
7314 });
7315 }
7316
7317 pub(crate) fn insert_dragged_files(
7318 &self,
7319 paths: Vec<project::ProjectPath>,
7320 added_worktrees: Vec<Entity<project::Worktree>>,
7321 window: &mut Window,
7322 cx: &mut Context<Self>,
7323 ) {
7324 self.message_editor.update(cx, |message_editor, cx| {
7325 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
7326 })
7327 }
7328
7329 /// Inserts the selected text into the message editor or the message being
7330 /// edited, if any.
7331 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
7332 self.active_editor(cx).update(cx, |editor, cx| {
7333 editor.insert_selections(window, cx);
7334 });
7335 }
7336
7337 /// Inserts code snippets as creases into the message editor.
7338 pub(crate) fn insert_code_crease(
7339 &self,
7340 creases: Vec<(String, String)>,
7341 window: &mut Window,
7342 cx: &mut Context<Self>,
7343 ) {
7344 self.message_editor.update(cx, |message_editor, cx| {
7345 message_editor.insert_code_creases(creases, window, cx);
7346 });
7347 }
7348
7349 fn render_thread_retry_status_callout(
7350 &self,
7351 _window: &mut Window,
7352 _cx: &mut Context<Self>,
7353 ) -> Option<Callout> {
7354 let state = self.thread_retry_status.as_ref()?;
7355
7356 let next_attempt_in = state
7357 .duration
7358 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
7359 if next_attempt_in.is_zero() {
7360 return None;
7361 }
7362
7363 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
7364
7365 let retry_message = if state.max_attempts == 1 {
7366 if next_attempt_in_secs == 1 {
7367 "Retrying. Next attempt in 1 second.".to_string()
7368 } else {
7369 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
7370 }
7371 } else if next_attempt_in_secs == 1 {
7372 format!(
7373 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
7374 state.attempt, state.max_attempts,
7375 )
7376 } else {
7377 format!(
7378 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
7379 state.attempt, state.max_attempts,
7380 )
7381 };
7382
7383 Some(
7384 Callout::new()
7385 .severity(Severity::Warning)
7386 .title(state.last_error.clone())
7387 .description(retry_message),
7388 )
7389 }
7390
7391 fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
7392 Callout::new()
7393 .icon(IconName::Warning)
7394 .severity(Severity::Warning)
7395 .title("Codex on Windows")
7396 .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
7397 .actions_slot(
7398 Button::new("open-wsl-modal", "Open in WSL")
7399 .icon_size(IconSize::Small)
7400 .icon_color(Color::Muted)
7401 .on_click(cx.listener({
7402 move |_, _, _window, cx| {
7403 #[cfg(windows)]
7404 _window.dispatch_action(
7405 zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
7406 cx,
7407 );
7408 cx.notify();
7409 }
7410 })),
7411 )
7412 .dismiss_action(
7413 IconButton::new("dismiss", IconName::Close)
7414 .icon_size(IconSize::Small)
7415 .icon_color(Color::Muted)
7416 .tooltip(Tooltip::text("Dismiss Warning"))
7417 .on_click(cx.listener({
7418 move |this, _, _, cx| {
7419 this.show_codex_windows_warning = false;
7420 cx.notify();
7421 }
7422 })),
7423 )
7424 }
7425
7426 fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
7427 let content = match self.thread_error.as_ref()? {
7428 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), window, cx),
7429 ThreadError::Refusal => self.render_refusal_error(cx),
7430 ThreadError::AuthenticationRequired(error) => {
7431 self.render_authentication_required_error(error.clone(), cx)
7432 }
7433 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
7434 };
7435
7436 Some(div().child(content))
7437 }
7438
7439 fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
7440 v_flex().w_full().justify_end().child(
7441 h_flex()
7442 .p_2()
7443 .pr_3()
7444 .w_full()
7445 .gap_1p5()
7446 .border_t_1()
7447 .border_color(cx.theme().colors().border)
7448 .bg(cx.theme().colors().element_background)
7449 .child(
7450 h_flex()
7451 .flex_1()
7452 .gap_1p5()
7453 .child(
7454 Icon::new(IconName::Download)
7455 .color(Color::Accent)
7456 .size(IconSize::Small),
7457 )
7458 .child(Label::new("New version available").size(LabelSize::Small)),
7459 )
7460 .child(
7461 Button::new("update-button", format!("Update to v{}", version))
7462 .label_size(LabelSize::Small)
7463 .style(ButtonStyle::Tinted(TintColor::Accent))
7464 .on_click(cx.listener(|this, _, window, cx| {
7465 this.reset(window, cx);
7466 })),
7467 ),
7468 )
7469 }
7470
7471 fn current_mode_id(&self, cx: &App) -> Option<Arc<str>> {
7472 if let Some(thread) = self.as_native_thread(cx) {
7473 Some(thread.read(cx).profile().0.clone())
7474 } else if let Some(mode_selector) = self.mode_selector() {
7475 Some(mode_selector.read(cx).mode().0)
7476 } else {
7477 None
7478 }
7479 }
7480
7481 fn current_model_id(&self, cx: &App) -> Option<String> {
7482 self.model_selector
7483 .as_ref()
7484 .and_then(|selector| selector.read(cx).active_model(cx).map(|m| m.id.to_string()))
7485 }
7486
7487 fn current_model_name(&self, cx: &App) -> SharedString {
7488 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
7489 // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
7490 // This provides better clarity about what refused the request
7491 if self.as_native_connection(cx).is_some() {
7492 self.model_selector
7493 .as_ref()
7494 .and_then(|selector| selector.read(cx).active_model(cx))
7495 .map(|model| model.name.clone())
7496 .unwrap_or_else(|| SharedString::from("The model"))
7497 } else {
7498 // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
7499 self.agent.name()
7500 }
7501 }
7502
7503 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
7504 let model_or_agent_name = self.current_model_name(cx);
7505 let refusal_message = format!(
7506 "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
7507 model_or_agent_name
7508 );
7509
7510 Callout::new()
7511 .severity(Severity::Error)
7512 .title("Request Refused")
7513 .icon(IconName::XCircle)
7514 .description(refusal_message.clone())
7515 .actions_slot(self.create_copy_button(&refusal_message))
7516 .dismiss_action(self.dismiss_error_button(cx))
7517 }
7518
7519 fn render_any_thread_error(
7520 &mut self,
7521 error: SharedString,
7522 window: &mut Window,
7523 cx: &mut Context<'_, Self>,
7524 ) -> Callout {
7525 let can_resume = self
7526 .thread()
7527 .map_or(false, |thread| thread.read(cx).can_resume(cx));
7528
7529 let markdown = if let Some(markdown) = &self.thread_error_markdown {
7530 markdown.clone()
7531 } else {
7532 let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
7533 self.thread_error_markdown = Some(markdown.clone());
7534 markdown
7535 };
7536
7537 let markdown_style = default_markdown_style(false, true, window, cx);
7538 let description = self
7539 .render_markdown(markdown, markdown_style)
7540 .into_any_element();
7541
7542 Callout::new()
7543 .severity(Severity::Error)
7544 .icon(IconName::XCircle)
7545 .title("An Error Happened")
7546 .description_slot(description)
7547 .actions_slot(
7548 h_flex()
7549 .gap_0p5()
7550 .when(can_resume, |this| {
7551 this.child(
7552 IconButton::new("retry", IconName::RotateCw)
7553 .icon_size(IconSize::Small)
7554 .tooltip(Tooltip::text("Retry Generation"))
7555 .on_click(cx.listener(|this, _, _window, cx| {
7556 this.resume_chat(cx);
7557 })),
7558 )
7559 })
7560 .child(self.create_copy_button(error.to_string())),
7561 )
7562 .dismiss_action(self.dismiss_error_button(cx))
7563 }
7564
7565 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
7566 const ERROR_MESSAGE: &str =
7567 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
7568
7569 Callout::new()
7570 .severity(Severity::Error)
7571 .icon(IconName::XCircle)
7572 .title("Free Usage Exceeded")
7573 .description(ERROR_MESSAGE)
7574 .actions_slot(
7575 h_flex()
7576 .gap_0p5()
7577 .child(self.upgrade_button(cx))
7578 .child(self.create_copy_button(ERROR_MESSAGE)),
7579 )
7580 .dismiss_action(self.dismiss_error_button(cx))
7581 }
7582
7583 fn render_authentication_required_error(
7584 &self,
7585 error: SharedString,
7586 cx: &mut Context<Self>,
7587 ) -> Callout {
7588 Callout::new()
7589 .severity(Severity::Error)
7590 .title("Authentication Required")
7591 .icon(IconName::XCircle)
7592 .description(error.clone())
7593 .actions_slot(
7594 h_flex()
7595 .gap_0p5()
7596 .child(self.authenticate_button(cx))
7597 .child(self.create_copy_button(error)),
7598 )
7599 .dismiss_action(self.dismiss_error_button(cx))
7600 }
7601
7602 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
7603 let message = message.into();
7604
7605 CopyButton::new(message).tooltip_label("Copy Error Message")
7606 }
7607
7608 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
7609 IconButton::new("dismiss", IconName::Close)
7610 .icon_size(IconSize::Small)
7611 .tooltip(Tooltip::text("Dismiss"))
7612 .on_click(cx.listener({
7613 move |this, _, _, cx| {
7614 this.clear_thread_error(cx);
7615 cx.notify();
7616 }
7617 }))
7618 }
7619
7620 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
7621 Button::new("authenticate", "Authenticate")
7622 .label_size(LabelSize::Small)
7623 .style(ButtonStyle::Filled)
7624 .on_click(cx.listener({
7625 move |this, _, window, cx| {
7626 let agent = this.agent.clone();
7627 let ThreadState::Ready { thread, .. } = &this.thread_state else {
7628 return;
7629 };
7630
7631 let connection = thread.read(cx).connection().clone();
7632 this.clear_thread_error(cx);
7633 if let Some(message) = this.in_flight_prompt.take() {
7634 this.message_editor.update(cx, |editor, cx| {
7635 editor.set_message(message, window, cx);
7636 });
7637 }
7638 let this = cx.weak_entity();
7639 window.defer(cx, |window, cx| {
7640 Self::handle_auth_required(
7641 this,
7642 AuthRequired::new(),
7643 agent,
7644 connection,
7645 window,
7646 cx,
7647 );
7648 })
7649 }
7650 }))
7651 }
7652
7653 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
7654 let agent = self.agent.clone();
7655 let ThreadState::Ready { thread, .. } = &self.thread_state else {
7656 return;
7657 };
7658
7659 let connection = thread.read(cx).connection().clone();
7660 self.clear_thread_error(cx);
7661 let this = cx.weak_entity();
7662 window.defer(cx, |window, cx| {
7663 Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx);
7664 })
7665 }
7666
7667 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
7668 Button::new("upgrade", "Upgrade")
7669 .label_size(LabelSize::Small)
7670 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
7671 .on_click(cx.listener({
7672 move |this, _, _, cx| {
7673 this.clear_thread_error(cx);
7674 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
7675 }
7676 }))
7677 }
7678
7679 pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context<Self>) {
7680 let task = self.history.update(cx, |history, cx| {
7681 history.delete_session(&entry.session_id, cx)
7682 });
7683 task.detach_and_log_err(cx);
7684 }
7685
7686 /// Returns the currently active editor, either for a message that is being
7687 /// edited or the editor for a new message.
7688 fn active_editor(&self, cx: &App) -> Entity<MessageEditor> {
7689 if let Some(index) = self.editing_message
7690 && let Some(editor) = self
7691 .entry_view_state
7692 .read(cx)
7693 .entry(index)
7694 .and_then(|e| e.message_editor())
7695 .cloned()
7696 {
7697 editor
7698 } else {
7699 self.message_editor.clone()
7700 }
7701 }
7702
7703 fn get_agent_message_content(
7704 entries: &[AgentThreadEntry],
7705 entry_index: usize,
7706 cx: &App,
7707 ) -> Option<String> {
7708 let entry = entries.get(entry_index)?;
7709 if matches!(entry, AgentThreadEntry::UserMessage(_)) {
7710 return None;
7711 }
7712
7713 let start_index = (0..entry_index)
7714 .rev()
7715 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
7716 .map(|i| i + 1)
7717 .unwrap_or(0);
7718
7719 let end_index = (entry_index + 1..entries.len())
7720 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
7721 .map(|i| i - 1)
7722 .unwrap_or(entries.len() - 1);
7723
7724 let parts: Vec<String> = (start_index..=end_index)
7725 .filter_map(|i| entries.get(i))
7726 .filter_map(|entry| {
7727 if let AgentThreadEntry::AssistantMessage(message) = entry {
7728 let text: String = message
7729 .chunks
7730 .iter()
7731 .filter_map(|chunk| match chunk {
7732 AssistantMessageChunk::Message { block } => {
7733 let markdown = block.to_markdown(cx);
7734 if markdown.trim().is_empty() {
7735 None
7736 } else {
7737 Some(markdown.to_string())
7738 }
7739 }
7740 AssistantMessageChunk::Thought { .. } => None,
7741 })
7742 .collect::<Vec<_>>()
7743 .join("\n\n");
7744
7745 if text.is_empty() { None } else { Some(text) }
7746 } else {
7747 None
7748 }
7749 })
7750 .collect();
7751
7752 let text = parts.join("\n\n");
7753 if text.is_empty() { None } else { Some(text) }
7754 }
7755}
7756
7757fn loading_contents_spinner(size: IconSize) -> AnyElement {
7758 Icon::new(IconName::LoadCircle)
7759 .size(size)
7760 .color(Color::Accent)
7761 .with_rotate_animation(3)
7762 .into_any_element()
7763}
7764
7765fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
7766 if agent_name == "Zed Agent" {
7767 format!("Message the {} — @ to include context", agent_name)
7768 } else if has_commands {
7769 format!(
7770 "Message {} — @ to include context, / for commands",
7771 agent_name
7772 )
7773 } else {
7774 format!("Message {} — @ to include context", agent_name)
7775 }
7776}
7777
7778impl Focusable for AcpThreadView {
7779 fn focus_handle(&self, cx: &App) -> FocusHandle {
7780 match self.thread_state {
7781 ThreadState::Ready { .. } => self.active_editor(cx).focus_handle(cx),
7782 ThreadState::Loading { .. }
7783 | ThreadState::LoadError(_)
7784 | ThreadState::Unauthenticated { .. } => self.focus_handle.clone(),
7785 }
7786 }
7787}
7788
7789#[cfg(any(test, feature = "test-support"))]
7790impl AcpThreadView {
7791 /// Expands a tool call so its content is visible.
7792 /// This is primarily useful for visual testing.
7793 pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
7794 self.expanded_tool_calls.insert(tool_call_id);
7795 cx.notify();
7796 }
7797
7798 /// Expands a subagent card so its content is visible.
7799 /// This is primarily useful for visual testing.
7800 pub fn expand_subagent(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
7801 self.expanded_subagents.insert(session_id);
7802 cx.notify();
7803 }
7804}
7805
7806impl Render for AcpThreadView {
7807 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
7808 let has_messages = self.list_state.item_count() > 0;
7809
7810 v_flex()
7811 .size_full()
7812 .key_context("AcpThread")
7813 .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
7814 this.cancel_generation(cx);
7815 }))
7816 .on_action(cx.listener(Self::keep_all))
7817 .on_action(cx.listener(Self::reject_all))
7818 .on_action(cx.listener(Self::allow_always))
7819 .on_action(cx.listener(Self::allow_once))
7820 .on_action(cx.listener(Self::reject_once))
7821 .on_action(cx.listener(Self::handle_authorize_tool_call))
7822 .on_action(cx.listener(Self::handle_select_permission_granularity))
7823 .on_action(cx.listener(Self::open_permission_dropdown))
7824 .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
7825 this.send_queued_message_at_index(0, true, window, cx);
7826 }))
7827 .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
7828 this.message_queue.clear();
7829 cx.notify();
7830 }))
7831 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
7832 if let Some(config_options_view) = this.config_options_view.as_ref() {
7833 let handled = config_options_view.update(cx, |view, cx| {
7834 view.toggle_category_picker(
7835 acp::SessionConfigOptionCategory::Mode,
7836 window,
7837 cx,
7838 )
7839 });
7840 if handled {
7841 return;
7842 }
7843 }
7844
7845 if let Some(profile_selector) = this.profile_selector.as_ref() {
7846 profile_selector.read(cx).menu_handle().toggle(window, cx);
7847 } else if let Some(mode_selector) = this.mode_selector() {
7848 mode_selector.read(cx).menu_handle().toggle(window, cx);
7849 }
7850 }))
7851 .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
7852 if let Some(config_options_view) = this.config_options_view.as_ref() {
7853 let handled = config_options_view.update(cx, |view, cx| {
7854 view.cycle_category_option(
7855 acp::SessionConfigOptionCategory::Mode,
7856 false,
7857 cx,
7858 )
7859 });
7860 if handled {
7861 return;
7862 }
7863 }
7864
7865 if let Some(profile_selector) = this.profile_selector.as_ref() {
7866 profile_selector.update(cx, |profile_selector, cx| {
7867 profile_selector.cycle_profile(cx);
7868 });
7869 } else if let Some(mode_selector) = this.mode_selector() {
7870 mode_selector.update(cx, |mode_selector, cx| {
7871 mode_selector.cycle_mode(window, cx);
7872 });
7873 }
7874 }))
7875 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
7876 if let Some(config_options_view) = this.config_options_view.as_ref() {
7877 let handled = config_options_view.update(cx, |view, cx| {
7878 view.toggle_category_picker(
7879 acp::SessionConfigOptionCategory::Model,
7880 window,
7881 cx,
7882 )
7883 });
7884 if handled {
7885 return;
7886 }
7887 }
7888
7889 if let Some(model_selector) = this.model_selector.as_ref() {
7890 model_selector
7891 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
7892 }
7893 }))
7894 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
7895 if let Some(config_options_view) = this.config_options_view.as_ref() {
7896 let handled = config_options_view.update(cx, |view, cx| {
7897 view.cycle_category_option(
7898 acp::SessionConfigOptionCategory::Model,
7899 true,
7900 cx,
7901 )
7902 });
7903 if handled {
7904 return;
7905 }
7906 }
7907
7908 if let Some(model_selector) = this.model_selector.as_ref() {
7909 model_selector.update(cx, |model_selector, cx| {
7910 model_selector.cycle_favorite_models(window, cx);
7911 });
7912 }
7913 }))
7914 .track_focus(&self.focus_handle)
7915 .bg(cx.theme().colors().panel_background)
7916 .child(match &self.thread_state {
7917 ThreadState::Unauthenticated {
7918 connection,
7919 description,
7920 configuration_view,
7921 pending_auth_method,
7922 ..
7923 } => v_flex()
7924 .flex_1()
7925 .size_full()
7926 .justify_end()
7927 .child(self.render_auth_required_state(
7928 connection,
7929 description.as_ref(),
7930 configuration_view.as_ref(),
7931 pending_auth_method.as_ref(),
7932 window,
7933 cx,
7934 ))
7935 .into_any_element(),
7936 ThreadState::Loading { .. } => v_flex()
7937 .flex_1()
7938 .child(self.render_recent_history(cx))
7939 .into_any(),
7940 ThreadState::LoadError(e) => v_flex()
7941 .flex_1()
7942 .size_full()
7943 .items_center()
7944 .justify_end()
7945 .child(self.render_load_error(e, window, cx))
7946 .into_any(),
7947 ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
7948 if has_messages {
7949 this.child(
7950 list(
7951 self.list_state.clone(),
7952 cx.processor(|this, index: usize, window, cx| {
7953 let Some((entry, len)) = this.thread().and_then(|thread| {
7954 let entries = &thread.read(cx).entries();
7955 Some((entries.get(index)?, entries.len()))
7956 }) else {
7957 return Empty.into_any();
7958 };
7959 this.render_entry(index, len, entry, window, cx)
7960 }),
7961 )
7962 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
7963 .flex_grow()
7964 .into_any(),
7965 )
7966 .vertical_scrollbar_for(&self.list_state, window, cx)
7967 .into_any()
7968 } else {
7969 this.child(self.render_recent_history(cx)).into_any()
7970 }
7971 }),
7972 })
7973 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
7974 // above so that the scrollbar doesn't render behind it. The current setup allows
7975 // the scrollbar to stop exactly at the activity bar start.
7976 .when(has_messages, |this| match &self.thread_state {
7977 ThreadState::Ready { thread, .. } => {
7978 this.children(self.render_activity_bar(thread, window, cx))
7979 }
7980 _ => this,
7981 })
7982 .children(self.render_thread_retry_status_callout(window, cx))
7983 .when(self.show_codex_windows_warning, |this| {
7984 this.child(self.render_codex_windows_warning(cx))
7985 })
7986 .children(self.render_thread_error(window, cx))
7987 .when_some(
7988 self.new_server_version_available.as_ref().filter(|_| {
7989 !has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
7990 }),
7991 |this, version| this.child(self.render_new_version_callout(&version, cx)),
7992 )
7993 .children(
7994 self.render_token_limit_callout(cx)
7995 .map(|token_limit_callout| token_limit_callout.into_any_element()),
7996 )
7997 .child(self.render_message_editor(window, cx))
7998 }
7999}
8000
8001fn default_markdown_style(
8002 buffer_font: bool,
8003 muted_text: bool,
8004 window: &Window,
8005 cx: &App,
8006) -> MarkdownStyle {
8007 let theme_settings = ThemeSettings::get_global(cx);
8008 let colors = cx.theme().colors();
8009
8010 let buffer_font_size = theme_settings.agent_buffer_font_size(cx);
8011
8012 let mut text_style = window.text_style();
8013 let line_height = buffer_font_size * 1.75;
8014
8015 let font_family = if buffer_font {
8016 theme_settings.buffer_font.family.clone()
8017 } else {
8018 theme_settings.ui_font.family.clone()
8019 };
8020
8021 let font_size = if buffer_font {
8022 theme_settings.agent_buffer_font_size(cx)
8023 } else {
8024 theme_settings.agent_ui_font_size(cx)
8025 };
8026
8027 let text_color = if muted_text {
8028 colors.text_muted
8029 } else {
8030 colors.text
8031 };
8032
8033 text_style.refine(&TextStyleRefinement {
8034 font_family: Some(font_family),
8035 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
8036 font_features: Some(theme_settings.ui_font.features.clone()),
8037 font_size: Some(font_size.into()),
8038 line_height: Some(line_height.into()),
8039 color: Some(text_color),
8040 ..Default::default()
8041 });
8042
8043 MarkdownStyle {
8044 base_text_style: text_style.clone(),
8045 syntax: cx.theme().syntax().clone(),
8046 selection_background_color: colors.element_selection_background,
8047 code_block_overflow_x_scroll: true,
8048 heading_level_styles: Some(HeadingLevelStyles {
8049 h1: Some(TextStyleRefinement {
8050 font_size: Some(rems(1.15).into()),
8051 ..Default::default()
8052 }),
8053 h2: Some(TextStyleRefinement {
8054 font_size: Some(rems(1.1).into()),
8055 ..Default::default()
8056 }),
8057 h3: Some(TextStyleRefinement {
8058 font_size: Some(rems(1.05).into()),
8059 ..Default::default()
8060 }),
8061 h4: Some(TextStyleRefinement {
8062 font_size: Some(rems(1.).into()),
8063 ..Default::default()
8064 }),
8065 h5: Some(TextStyleRefinement {
8066 font_size: Some(rems(0.95).into()),
8067 ..Default::default()
8068 }),
8069 h6: Some(TextStyleRefinement {
8070 font_size: Some(rems(0.875).into()),
8071 ..Default::default()
8072 }),
8073 }),
8074 code_block: StyleRefinement {
8075 padding: EdgesRefinement {
8076 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
8077 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
8078 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
8079 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
8080 },
8081 margin: EdgesRefinement {
8082 top: Some(Length::Definite(px(8.).into())),
8083 left: Some(Length::Definite(px(0.).into())),
8084 right: Some(Length::Definite(px(0.).into())),
8085 bottom: Some(Length::Definite(px(12.).into())),
8086 },
8087 border_style: Some(BorderStyle::Solid),
8088 border_widths: EdgesRefinement {
8089 top: Some(AbsoluteLength::Pixels(px(1.))),
8090 left: Some(AbsoluteLength::Pixels(px(1.))),
8091 right: Some(AbsoluteLength::Pixels(px(1.))),
8092 bottom: Some(AbsoluteLength::Pixels(px(1.))),
8093 },
8094 border_color: Some(colors.border_variant),
8095 background: Some(colors.editor_background.into()),
8096 text: TextStyleRefinement {
8097 font_family: Some(theme_settings.buffer_font.family.clone()),
8098 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
8099 font_features: Some(theme_settings.buffer_font.features.clone()),
8100 font_size: Some(buffer_font_size.into()),
8101 ..Default::default()
8102 },
8103 ..Default::default()
8104 },
8105 inline_code: TextStyleRefinement {
8106 font_family: Some(theme_settings.buffer_font.family.clone()),
8107 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
8108 font_features: Some(theme_settings.buffer_font.features.clone()),
8109 font_size: Some(buffer_font_size.into()),
8110 background_color: Some(colors.editor_foreground.opacity(0.08)),
8111 ..Default::default()
8112 },
8113 link: TextStyleRefinement {
8114 background_color: Some(colors.editor_foreground.opacity(0.025)),
8115 color: Some(colors.text_accent),
8116 underline: Some(UnderlineStyle {
8117 color: Some(colors.text_accent.opacity(0.5)),
8118 thickness: px(1.),
8119 ..Default::default()
8120 }),
8121 ..Default::default()
8122 },
8123 ..Default::default()
8124 }
8125}
8126
8127fn plan_label_markdown_style(
8128 status: &acp::PlanEntryStatus,
8129 window: &Window,
8130 cx: &App,
8131) -> MarkdownStyle {
8132 let default_md_style = default_markdown_style(false, false, window, cx);
8133
8134 MarkdownStyle {
8135 base_text_style: TextStyle {
8136 color: cx.theme().colors().text_muted,
8137 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
8138 Some(gpui::StrikethroughStyle {
8139 thickness: px(1.),
8140 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
8141 })
8142 } else {
8143 None
8144 },
8145 ..default_md_style.base_text_style
8146 },
8147 ..default_md_style
8148 }
8149}
8150
8151#[cfg(test)]
8152pub(crate) mod tests {
8153 use acp_thread::{
8154 AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
8155 };
8156 use action_log::ActionLog;
8157 use agent::ToolPermissionContext;
8158 use agent_client_protocol::SessionId;
8159 use editor::MultiBufferOffset;
8160 use fs::FakeFs;
8161 use gpui::{EventEmitter, TestAppContext, VisualTestContext};
8162 use project::Project;
8163 use serde_json::json;
8164 use settings::SettingsStore;
8165 use std::any::Any;
8166 use std::path::Path;
8167 use std::rc::Rc;
8168 use workspace::Item;
8169
8170 use super::*;
8171
8172 #[gpui::test]
8173 async fn test_drop(cx: &mut TestAppContext) {
8174 init_test(cx);
8175
8176 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
8177 let weak_view = thread_view.downgrade();
8178 drop(thread_view);
8179 assert!(!weak_view.is_upgradable());
8180 }
8181
8182 #[gpui::test]
8183 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
8184 init_test(cx);
8185
8186 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
8187
8188 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8189 message_editor.update_in(cx, |editor, window, cx| {
8190 editor.set_text("Hello", window, cx);
8191 });
8192
8193 cx.deactivate_window();
8194
8195 thread_view.update_in(cx, |thread_view, window, cx| {
8196 thread_view.send(window, cx);
8197 });
8198
8199 cx.run_until_parked();
8200
8201 assert!(
8202 cx.windows()
8203 .iter()
8204 .any(|window| window.downcast::<AgentNotification>().is_some())
8205 );
8206 }
8207
8208 #[gpui::test]
8209 async fn test_notification_for_error(cx: &mut TestAppContext) {
8210 init_test(cx);
8211
8212 let (thread_view, cx) =
8213 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
8214
8215 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8216 message_editor.update_in(cx, |editor, window, cx| {
8217 editor.set_text("Hello", window, cx);
8218 });
8219
8220 cx.deactivate_window();
8221
8222 thread_view.update_in(cx, |thread_view, window, cx| {
8223 thread_view.send(window, cx);
8224 });
8225
8226 cx.run_until_parked();
8227
8228 assert!(
8229 cx.windows()
8230 .iter()
8231 .any(|window| window.downcast::<AgentNotification>().is_some())
8232 );
8233 }
8234
8235 #[gpui::test]
8236 async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
8237 init_test(cx);
8238
8239 let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
8240 let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
8241
8242 let fs = FakeFs::new(cx.executor());
8243 let project = Project::test(fs, [], cx).await;
8244 let (workspace, cx) =
8245 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
8246
8247 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
8248 // Create history without an initial session list - it will be set after connection
8249 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
8250
8251 let thread_view = cx.update(|window, cx| {
8252 cx.new(|cx| {
8253 AcpThreadView::new(
8254 Rc::new(StubAgentServer::default_response()),
8255 None,
8256 None,
8257 workspace.downgrade(),
8258 project,
8259 Some(thread_store),
8260 None,
8261 history.clone(),
8262 false,
8263 window,
8264 cx,
8265 )
8266 })
8267 });
8268
8269 // Wait for connection to establish
8270 cx.run_until_parked();
8271
8272 // Initially empty because StubAgentConnection.session_list() returns None
8273 thread_view.read_with(cx, |view, _cx| {
8274 assert_eq!(view.recent_history_entries.len(), 0);
8275 });
8276
8277 // Now set the session list - this simulates external agents providing their history
8278 let list_a: Rc<dyn AgentSessionList> =
8279 Rc::new(StubSessionList::new(vec![session_a.clone()]));
8280 history.update(cx, |history, cx| {
8281 history.set_session_list(Some(list_a), cx);
8282 });
8283 cx.run_until_parked();
8284
8285 thread_view.read_with(cx, |view, _cx| {
8286 assert_eq!(view.recent_history_entries.len(), 1);
8287 assert_eq!(
8288 view.recent_history_entries[0].session_id,
8289 session_a.session_id
8290 );
8291 });
8292
8293 // Update to a different session list
8294 let list_b: Rc<dyn AgentSessionList> =
8295 Rc::new(StubSessionList::new(vec![session_b.clone()]));
8296 history.update(cx, |history, cx| {
8297 history.set_session_list(Some(list_b), cx);
8298 });
8299 cx.run_until_parked();
8300
8301 thread_view.read_with(cx, |view, _cx| {
8302 assert_eq!(view.recent_history_entries.len(), 1);
8303 assert_eq!(
8304 view.recent_history_entries[0].session_id,
8305 session_b.session_id
8306 );
8307 });
8308 }
8309
8310 #[gpui::test]
8311 async fn test_refusal_handling(cx: &mut TestAppContext) {
8312 init_test(cx);
8313
8314 let (thread_view, cx) =
8315 setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
8316
8317 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8318 message_editor.update_in(cx, |editor, window, cx| {
8319 editor.set_text("Do something harmful", window, cx);
8320 });
8321
8322 thread_view.update_in(cx, |thread_view, window, cx| {
8323 thread_view.send(window, cx);
8324 });
8325
8326 cx.run_until_parked();
8327
8328 // Check that the refusal error is set
8329 thread_view.read_with(cx, |thread_view, _cx| {
8330 assert!(
8331 matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
8332 "Expected refusal error to be set"
8333 );
8334 });
8335 }
8336
8337 #[gpui::test]
8338 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
8339 init_test(cx);
8340
8341 let tool_call_id = acp::ToolCallId::new("1");
8342 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
8343 .kind(acp::ToolKind::Edit)
8344 .content(vec!["hi".into()]);
8345 let connection =
8346 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
8347 tool_call_id,
8348 vec![acp::PermissionOption::new(
8349 "1",
8350 "Allow",
8351 acp::PermissionOptionKind::AllowOnce,
8352 )],
8353 )]));
8354
8355 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
8356
8357 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
8358
8359 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8360 message_editor.update_in(cx, |editor, window, cx| {
8361 editor.set_text("Hello", window, cx);
8362 });
8363
8364 cx.deactivate_window();
8365
8366 thread_view.update_in(cx, |thread_view, window, cx| {
8367 thread_view.send(window, cx);
8368 });
8369
8370 cx.run_until_parked();
8371
8372 assert!(
8373 cx.windows()
8374 .iter()
8375 .any(|window| window.downcast::<AgentNotification>().is_some())
8376 );
8377 }
8378
8379 #[gpui::test]
8380 async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
8381 init_test(cx);
8382
8383 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
8384
8385 add_to_workspace(thread_view.clone(), cx);
8386
8387 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8388
8389 message_editor.update_in(cx, |editor, window, cx| {
8390 editor.set_text("Hello", window, cx);
8391 });
8392
8393 // Window is active (don't deactivate), but panel will be hidden
8394 // Note: In the test environment, the panel is not actually added to the dock,
8395 // so is_agent_panel_hidden will return true
8396
8397 thread_view.update_in(cx, |thread_view, window, cx| {
8398 thread_view.send(window, cx);
8399 });
8400
8401 cx.run_until_parked();
8402
8403 // Should show notification because window is active but panel is hidden
8404 assert!(
8405 cx.windows()
8406 .iter()
8407 .any(|window| window.downcast::<AgentNotification>().is_some()),
8408 "Expected notification when panel is hidden"
8409 );
8410 }
8411
8412 #[gpui::test]
8413 async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
8414 init_test(cx);
8415
8416 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
8417
8418 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8419 message_editor.update_in(cx, |editor, window, cx| {
8420 editor.set_text("Hello", window, cx);
8421 });
8422
8423 // Deactivate window - should show notification regardless of setting
8424 cx.deactivate_window();
8425
8426 thread_view.update_in(cx, |thread_view, window, cx| {
8427 thread_view.send(window, cx);
8428 });
8429
8430 cx.run_until_parked();
8431
8432 // Should still show notification when window is inactive (existing behavior)
8433 assert!(
8434 cx.windows()
8435 .iter()
8436 .any(|window| window.downcast::<AgentNotification>().is_some()),
8437 "Expected notification when window is inactive"
8438 );
8439 }
8440
8441 #[gpui::test]
8442 async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
8443 init_test(cx);
8444
8445 // Set notify_when_agent_waiting to Never
8446 cx.update(|cx| {
8447 AgentSettings::override_global(
8448 AgentSettings {
8449 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
8450 ..AgentSettings::get_global(cx).clone()
8451 },
8452 cx,
8453 );
8454 });
8455
8456 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
8457
8458 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8459 message_editor.update_in(cx, |editor, window, cx| {
8460 editor.set_text("Hello", window, cx);
8461 });
8462
8463 // Window is active
8464
8465 thread_view.update_in(cx, |thread_view, window, cx| {
8466 thread_view.send(window, cx);
8467 });
8468
8469 cx.run_until_parked();
8470
8471 // Should NOT show notification because notify_when_agent_waiting is Never
8472 assert!(
8473 !cx.windows()
8474 .iter()
8475 .any(|window| window.downcast::<AgentNotification>().is_some()),
8476 "Expected no notification when notify_when_agent_waiting is Never"
8477 );
8478 }
8479
8480 #[gpui::test]
8481 async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
8482 init_test(cx);
8483
8484 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
8485
8486 let weak_view = thread_view.downgrade();
8487
8488 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8489 message_editor.update_in(cx, |editor, window, cx| {
8490 editor.set_text("Hello", window, cx);
8491 });
8492
8493 cx.deactivate_window();
8494
8495 thread_view.update_in(cx, |thread_view, window, cx| {
8496 thread_view.send(window, cx);
8497 });
8498
8499 cx.run_until_parked();
8500
8501 // Verify notification is shown
8502 assert!(
8503 cx.windows()
8504 .iter()
8505 .any(|window| window.downcast::<AgentNotification>().is_some()),
8506 "Expected notification to be shown"
8507 );
8508
8509 // Drop the thread view (simulating navigation to a new thread)
8510 drop(thread_view);
8511 drop(message_editor);
8512 // Trigger an update to flush effects, which will call release_dropped_entities
8513 cx.update(|_window, _cx| {});
8514 cx.run_until_parked();
8515
8516 // Verify the entity was actually released
8517 assert!(
8518 !weak_view.is_upgradable(),
8519 "Thread view entity should be released after dropping"
8520 );
8521
8522 // The notification should be automatically closed via on_release
8523 assert!(
8524 !cx.windows()
8525 .iter()
8526 .any(|window| window.downcast::<AgentNotification>().is_some()),
8527 "Notification should be closed when thread view is dropped"
8528 );
8529 }
8530
8531 async fn setup_thread_view(
8532 agent: impl AgentServer + 'static,
8533 cx: &mut TestAppContext,
8534 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
8535 let fs = FakeFs::new(cx.executor());
8536 let project = Project::test(fs, [], cx).await;
8537 let (workspace, cx) =
8538 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
8539
8540 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
8541 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
8542
8543 let thread_view = cx.update(|window, cx| {
8544 cx.new(|cx| {
8545 AcpThreadView::new(
8546 Rc::new(agent),
8547 None,
8548 None,
8549 workspace.downgrade(),
8550 project,
8551 Some(thread_store),
8552 None,
8553 history,
8554 false,
8555 window,
8556 cx,
8557 )
8558 })
8559 });
8560 cx.run_until_parked();
8561 (thread_view, cx)
8562 }
8563
8564 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
8565 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
8566
8567 workspace
8568 .update_in(cx, |workspace, window, cx| {
8569 workspace.add_item_to_active_pane(
8570 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
8571 None,
8572 true,
8573 window,
8574 cx,
8575 );
8576 })
8577 .unwrap();
8578 }
8579
8580 struct ThreadViewItem(Entity<AcpThreadView>);
8581
8582 impl Item for ThreadViewItem {
8583 type Event = ();
8584
8585 fn include_in_nav_history() -> bool {
8586 false
8587 }
8588
8589 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
8590 "Test".into()
8591 }
8592 }
8593
8594 impl EventEmitter<()> for ThreadViewItem {}
8595
8596 impl Focusable for ThreadViewItem {
8597 fn focus_handle(&self, cx: &App) -> FocusHandle {
8598 self.0.read(cx).focus_handle(cx)
8599 }
8600 }
8601
8602 impl Render for ThreadViewItem {
8603 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
8604 self.0.clone().into_any_element()
8605 }
8606 }
8607
8608 struct StubAgentServer<C> {
8609 connection: C,
8610 }
8611
8612 impl<C> StubAgentServer<C> {
8613 fn new(connection: C) -> Self {
8614 Self { connection }
8615 }
8616 }
8617
8618 impl StubAgentServer<StubAgentConnection> {
8619 fn default_response() -> Self {
8620 let conn = StubAgentConnection::new();
8621 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8622 acp::ContentChunk::new("Default response".into()),
8623 )]);
8624 Self::new(conn)
8625 }
8626 }
8627
8628 #[derive(Clone)]
8629 struct StubSessionList {
8630 sessions: Vec<AgentSessionInfo>,
8631 }
8632
8633 impl StubSessionList {
8634 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
8635 Self { sessions }
8636 }
8637 }
8638
8639 impl AgentSessionList for StubSessionList {
8640 fn list_sessions(
8641 &self,
8642 _request: AgentSessionListRequest,
8643 _cx: &mut App,
8644 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
8645 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
8646 }
8647 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
8648 self
8649 }
8650 }
8651
8652 impl<C> AgentServer for StubAgentServer<C>
8653 where
8654 C: 'static + AgentConnection + Send + Clone,
8655 {
8656 fn logo(&self) -> ui::IconName {
8657 ui::IconName::Ai
8658 }
8659
8660 fn name(&self) -> SharedString {
8661 "Test".into()
8662 }
8663
8664 fn connect(
8665 &self,
8666 _root_dir: Option<&Path>,
8667 _delegate: AgentServerDelegate,
8668 _cx: &mut App,
8669 ) -> Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
8670 Task::ready(Ok((Rc::new(self.connection.clone()), None)))
8671 }
8672
8673 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
8674 self
8675 }
8676 }
8677
8678 #[derive(Clone)]
8679 struct SaboteurAgentConnection;
8680
8681 impl AgentConnection for SaboteurAgentConnection {
8682 fn telemetry_id(&self) -> SharedString {
8683 "saboteur".into()
8684 }
8685
8686 fn new_thread(
8687 self: Rc<Self>,
8688 project: Entity<Project>,
8689 _cwd: &Path,
8690 cx: &mut gpui::App,
8691 ) -> Task<gpui::Result<Entity<AcpThread>>> {
8692 Task::ready(Ok(cx.new(|cx| {
8693 let action_log = cx.new(|_| ActionLog::new(project.clone()));
8694 AcpThread::new(
8695 "SaboteurAgentConnection",
8696 self,
8697 project,
8698 action_log,
8699 SessionId::new("test"),
8700 watch::Receiver::constant(
8701 acp::PromptCapabilities::new()
8702 .image(true)
8703 .audio(true)
8704 .embedded_context(true),
8705 ),
8706 cx,
8707 )
8708 })))
8709 }
8710
8711 fn auth_methods(&self) -> &[acp::AuthMethod] {
8712 &[]
8713 }
8714
8715 fn authenticate(
8716 &self,
8717 _method_id: acp::AuthMethodId,
8718 _cx: &mut App,
8719 ) -> Task<gpui::Result<()>> {
8720 unimplemented!()
8721 }
8722
8723 fn prompt(
8724 &self,
8725 _id: Option<acp_thread::UserMessageId>,
8726 _params: acp::PromptRequest,
8727 _cx: &mut App,
8728 ) -> Task<gpui::Result<acp::PromptResponse>> {
8729 Task::ready(Err(anyhow::anyhow!("Error prompting")))
8730 }
8731
8732 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
8733 unimplemented!()
8734 }
8735
8736 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
8737 self
8738 }
8739 }
8740
8741 /// Simulates a model which always returns a refusal response
8742 #[derive(Clone)]
8743 struct RefusalAgentConnection;
8744
8745 impl AgentConnection for RefusalAgentConnection {
8746 fn telemetry_id(&self) -> SharedString {
8747 "refusal".into()
8748 }
8749
8750 fn new_thread(
8751 self: Rc<Self>,
8752 project: Entity<Project>,
8753 _cwd: &Path,
8754 cx: &mut gpui::App,
8755 ) -> Task<gpui::Result<Entity<AcpThread>>> {
8756 Task::ready(Ok(cx.new(|cx| {
8757 let action_log = cx.new(|_| ActionLog::new(project.clone()));
8758 AcpThread::new(
8759 "RefusalAgentConnection",
8760 self,
8761 project,
8762 action_log,
8763 SessionId::new("test"),
8764 watch::Receiver::constant(
8765 acp::PromptCapabilities::new()
8766 .image(true)
8767 .audio(true)
8768 .embedded_context(true),
8769 ),
8770 cx,
8771 )
8772 })))
8773 }
8774
8775 fn auth_methods(&self) -> &[acp::AuthMethod] {
8776 &[]
8777 }
8778
8779 fn authenticate(
8780 &self,
8781 _method_id: acp::AuthMethodId,
8782 _cx: &mut App,
8783 ) -> Task<gpui::Result<()>> {
8784 unimplemented!()
8785 }
8786
8787 fn prompt(
8788 &self,
8789 _id: Option<acp_thread::UserMessageId>,
8790 _params: acp::PromptRequest,
8791 _cx: &mut App,
8792 ) -> Task<gpui::Result<acp::PromptResponse>> {
8793 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
8794 }
8795
8796 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
8797 unimplemented!()
8798 }
8799
8800 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
8801 self
8802 }
8803 }
8804
8805 pub(crate) fn init_test(cx: &mut TestAppContext) {
8806 cx.update(|cx| {
8807 let settings_store = SettingsStore::test(cx);
8808 cx.set_global(settings_store);
8809 theme::init(theme::LoadThemes::JustBase, cx);
8810 release_channel::init(semver::Version::new(0, 0, 0), cx);
8811 prompt_store::init(cx)
8812 });
8813 }
8814
8815 #[gpui::test]
8816 async fn test_rewind_views(cx: &mut TestAppContext) {
8817 init_test(cx);
8818
8819 let fs = FakeFs::new(cx.executor());
8820 fs.insert_tree(
8821 "/project",
8822 json!({
8823 "test1.txt": "old content 1",
8824 "test2.txt": "old content 2"
8825 }),
8826 )
8827 .await;
8828 let project = Project::test(fs, [Path::new("/project")], cx).await;
8829 let (workspace, cx) =
8830 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
8831
8832 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
8833 let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
8834
8835 let connection = Rc::new(StubAgentConnection::new());
8836 let thread_view = cx.update(|window, cx| {
8837 cx.new(|cx| {
8838 AcpThreadView::new(
8839 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
8840 None,
8841 None,
8842 workspace.downgrade(),
8843 project.clone(),
8844 Some(thread_store.clone()),
8845 None,
8846 history,
8847 false,
8848 window,
8849 cx,
8850 )
8851 })
8852 });
8853
8854 cx.run_until_parked();
8855
8856 let thread = thread_view
8857 .read_with(cx, |view, _| view.thread().cloned())
8858 .unwrap();
8859
8860 // First user message
8861 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
8862 acp::ToolCall::new("tool1", "Edit file 1")
8863 .kind(acp::ToolKind::Edit)
8864 .status(acp::ToolCallStatus::Completed)
8865 .content(vec![acp::ToolCallContent::Diff(
8866 acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
8867 )]),
8868 )]);
8869
8870 thread
8871 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
8872 .await
8873 .unwrap();
8874 cx.run_until_parked();
8875
8876 thread.read_with(cx, |thread, _| {
8877 assert_eq!(thread.entries().len(), 2);
8878 });
8879
8880 thread_view.read_with(cx, |view, cx| {
8881 view.entry_view_state.read_with(cx, |entry_view_state, _| {
8882 assert!(
8883 entry_view_state
8884 .entry(0)
8885 .unwrap()
8886 .message_editor()
8887 .is_some()
8888 );
8889 assert!(entry_view_state.entry(1).unwrap().has_content());
8890 });
8891 });
8892
8893 // Second user message
8894 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
8895 acp::ToolCall::new("tool2", "Edit file 2")
8896 .kind(acp::ToolKind::Edit)
8897 .status(acp::ToolCallStatus::Completed)
8898 .content(vec![acp::ToolCallContent::Diff(
8899 acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
8900 )]),
8901 )]);
8902
8903 thread
8904 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
8905 .await
8906 .unwrap();
8907 cx.run_until_parked();
8908
8909 let second_user_message_id = thread.read_with(cx, |thread, _| {
8910 assert_eq!(thread.entries().len(), 4);
8911 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
8912 panic!();
8913 };
8914 user_message.id.clone().unwrap()
8915 });
8916
8917 thread_view.read_with(cx, |view, cx| {
8918 view.entry_view_state.read_with(cx, |entry_view_state, _| {
8919 assert!(
8920 entry_view_state
8921 .entry(0)
8922 .unwrap()
8923 .message_editor()
8924 .is_some()
8925 );
8926 assert!(entry_view_state.entry(1).unwrap().has_content());
8927 assert!(
8928 entry_view_state
8929 .entry(2)
8930 .unwrap()
8931 .message_editor()
8932 .is_some()
8933 );
8934 assert!(entry_view_state.entry(3).unwrap().has_content());
8935 });
8936 });
8937
8938 // Rewind to first message
8939 thread
8940 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
8941 .await
8942 .unwrap();
8943
8944 cx.run_until_parked();
8945
8946 thread.read_with(cx, |thread, _| {
8947 assert_eq!(thread.entries().len(), 2);
8948 });
8949
8950 thread_view.read_with(cx, |view, cx| {
8951 view.entry_view_state.read_with(cx, |entry_view_state, _| {
8952 assert!(
8953 entry_view_state
8954 .entry(0)
8955 .unwrap()
8956 .message_editor()
8957 .is_some()
8958 );
8959 assert!(entry_view_state.entry(1).unwrap().has_content());
8960
8961 // Old views should be dropped
8962 assert!(entry_view_state.entry(2).is_none());
8963 assert!(entry_view_state.entry(3).is_none());
8964 });
8965 });
8966 }
8967
8968 #[gpui::test]
8969 async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
8970 init_test(cx);
8971
8972 let connection = StubAgentConnection::new();
8973
8974 // Each user prompt will result in a user message entry plus an agent message entry.
8975 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8976 acp::ContentChunk::new("Response 1".into()),
8977 )]);
8978
8979 let (thread_view, cx) =
8980 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
8981
8982 let thread = thread_view
8983 .read_with(cx, |view, _| view.thread().cloned())
8984 .unwrap();
8985
8986 thread
8987 .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
8988 .await
8989 .unwrap();
8990 cx.run_until_parked();
8991
8992 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8993 acp::ContentChunk::new("Response 2".into()),
8994 )]);
8995
8996 thread
8997 .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
8998 .await
8999 .unwrap();
9000 cx.run_until_parked();
9001
9002 // Move somewhere else first so we're not trivially already on the last user prompt.
9003 thread_view.update(cx, |view, cx| {
9004 view.scroll_to_top(cx);
9005 });
9006 cx.run_until_parked();
9007
9008 thread_view.update(cx, |view, cx| {
9009 view.scroll_to_most_recent_user_prompt(cx);
9010 let scroll_top = view.list_state.logical_scroll_top();
9011 // Entries layout is: [User1, Assistant1, User2, Assistant2]
9012 assert_eq!(scroll_top.item_ix, 2);
9013 });
9014 }
9015
9016 #[gpui::test]
9017 async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
9018 cx: &mut TestAppContext,
9019 ) {
9020 init_test(cx);
9021
9022 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
9023
9024 // With no entries, scrolling should be a no-op and must not panic.
9025 thread_view.update(cx, |view, cx| {
9026 view.scroll_to_most_recent_user_prompt(cx);
9027 let scroll_top = view.list_state.logical_scroll_top();
9028 assert_eq!(scroll_top.item_ix, 0);
9029 });
9030 }
9031
9032 #[gpui::test]
9033 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
9034 init_test(cx);
9035
9036 let connection = StubAgentConnection::new();
9037
9038 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
9039 acp::ContentChunk::new("Response".into()),
9040 )]);
9041
9042 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9043 add_to_workspace(thread_view.clone(), cx);
9044
9045 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9046 message_editor.update_in(cx, |editor, window, cx| {
9047 editor.set_text("Original message to edit", window, cx);
9048 });
9049 thread_view.update_in(cx, |thread_view, window, cx| {
9050 thread_view.send(window, cx);
9051 });
9052
9053 cx.run_until_parked();
9054
9055 let user_message_editor = thread_view.read_with(cx, |view, cx| {
9056 assert_eq!(view.editing_message, None);
9057
9058 view.entry_view_state
9059 .read(cx)
9060 .entry(0)
9061 .unwrap()
9062 .message_editor()
9063 .unwrap()
9064 .clone()
9065 });
9066
9067 // Focus
9068 cx.focus(&user_message_editor);
9069 thread_view.read_with(cx, |view, _cx| {
9070 assert_eq!(view.editing_message, Some(0));
9071 });
9072
9073 // Edit
9074 user_message_editor.update_in(cx, |editor, window, cx| {
9075 editor.set_text("Edited message content", window, cx);
9076 });
9077
9078 // Cancel
9079 user_message_editor.update_in(cx, |_editor, window, cx| {
9080 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
9081 });
9082
9083 thread_view.read_with(cx, |view, _cx| {
9084 assert_eq!(view.editing_message, None);
9085 });
9086
9087 user_message_editor.read_with(cx, |editor, cx| {
9088 assert_eq!(editor.text(cx), "Original message to edit");
9089 });
9090 }
9091
9092 #[gpui::test]
9093 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
9094 init_test(cx);
9095
9096 let connection = StubAgentConnection::new();
9097
9098 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9099 add_to_workspace(thread_view.clone(), cx);
9100
9101 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9102 let mut events = cx.events(&message_editor);
9103 message_editor.update_in(cx, |editor, window, cx| {
9104 editor.set_text("", window, cx);
9105 });
9106
9107 message_editor.update_in(cx, |_editor, window, cx| {
9108 window.dispatch_action(Box::new(Chat), cx);
9109 });
9110 cx.run_until_parked();
9111 // We shouldn't have received any messages
9112 assert!(matches!(
9113 events.try_next(),
9114 Err(futures::channel::mpsc::TryRecvError { .. })
9115 ));
9116 }
9117
9118 #[gpui::test]
9119 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
9120 init_test(cx);
9121
9122 let connection = StubAgentConnection::new();
9123
9124 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
9125 acp::ContentChunk::new("Response".into()),
9126 )]);
9127
9128 let (thread_view, cx) =
9129 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
9130 add_to_workspace(thread_view.clone(), cx);
9131
9132 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9133 message_editor.update_in(cx, |editor, window, cx| {
9134 editor.set_text("Original message to edit", window, cx);
9135 });
9136 thread_view.update_in(cx, |thread_view, window, cx| {
9137 thread_view.send(window, cx);
9138 });
9139
9140 cx.run_until_parked();
9141
9142 let user_message_editor = thread_view.read_with(cx, |view, cx| {
9143 assert_eq!(view.editing_message, None);
9144 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
9145
9146 view.entry_view_state
9147 .read(cx)
9148 .entry(0)
9149 .unwrap()
9150 .message_editor()
9151 .unwrap()
9152 .clone()
9153 });
9154
9155 // Focus
9156 cx.focus(&user_message_editor);
9157
9158 // Edit
9159 user_message_editor.update_in(cx, |editor, window, cx| {
9160 editor.set_text("Edited message content", window, cx);
9161 });
9162
9163 // Send
9164 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
9165 acp::ContentChunk::new("New Response".into()),
9166 )]);
9167
9168 user_message_editor.update_in(cx, |_editor, window, cx| {
9169 window.dispatch_action(Box::new(Chat), cx);
9170 });
9171
9172 cx.run_until_parked();
9173
9174 thread_view.read_with(cx, |view, cx| {
9175 assert_eq!(view.editing_message, None);
9176
9177 let entries = view.thread().unwrap().read(cx).entries();
9178 assert_eq!(entries.len(), 2);
9179 assert_eq!(
9180 entries[0].to_markdown(cx),
9181 "## User\n\nEdited message content\n\n"
9182 );
9183 assert_eq!(
9184 entries[1].to_markdown(cx),
9185 "## Assistant\n\nNew Response\n\n"
9186 );
9187
9188 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
9189 assert!(!state.entry(1).unwrap().has_content());
9190 state.entry(0).unwrap().message_editor().unwrap().clone()
9191 });
9192
9193 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
9194 })
9195 }
9196
9197 #[gpui::test]
9198 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
9199 init_test(cx);
9200
9201 let connection = StubAgentConnection::new();
9202
9203 let (thread_view, cx) =
9204 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
9205 add_to_workspace(thread_view.clone(), cx);
9206
9207 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9208 message_editor.update_in(cx, |editor, window, cx| {
9209 editor.set_text("Original message to edit", window, cx);
9210 });
9211 thread_view.update_in(cx, |thread_view, window, cx| {
9212 thread_view.send(window, cx);
9213 });
9214
9215 cx.run_until_parked();
9216
9217 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
9218 let thread = view.thread().unwrap().read(cx);
9219 assert_eq!(thread.entries().len(), 1);
9220
9221 let editor = view
9222 .entry_view_state
9223 .read(cx)
9224 .entry(0)
9225 .unwrap()
9226 .message_editor()
9227 .unwrap()
9228 .clone();
9229
9230 (editor, thread.session_id().clone())
9231 });
9232
9233 // Focus
9234 cx.focus(&user_message_editor);
9235
9236 thread_view.read_with(cx, |view, _cx| {
9237 assert_eq!(view.editing_message, Some(0));
9238 });
9239
9240 // Edit
9241 user_message_editor.update_in(cx, |editor, window, cx| {
9242 editor.set_text("Edited message content", window, cx);
9243 });
9244
9245 thread_view.read_with(cx, |view, _cx| {
9246 assert_eq!(view.editing_message, Some(0));
9247 });
9248
9249 // Finish streaming response
9250 cx.update(|_, cx| {
9251 connection.send_update(
9252 session_id.clone(),
9253 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
9254 cx,
9255 );
9256 connection.end_turn(session_id, acp::StopReason::EndTurn);
9257 });
9258
9259 thread_view.read_with(cx, |view, _cx| {
9260 assert_eq!(view.editing_message, Some(0));
9261 });
9262
9263 cx.run_until_parked();
9264
9265 // Should still be editing
9266 cx.update(|window, cx| {
9267 assert!(user_message_editor.focus_handle(cx).is_focused(window));
9268 assert_eq!(thread_view.read(cx).editing_message, Some(0));
9269 assert_eq!(
9270 user_message_editor.read(cx).text(cx),
9271 "Edited message content"
9272 );
9273 });
9274 }
9275
9276 struct GeneratingThreadSetup {
9277 thread_view: Entity<AcpThreadView>,
9278 thread: Entity<AcpThread>,
9279 message_editor: Entity<MessageEditor>,
9280 }
9281
9282 async fn setup_generating_thread(
9283 cx: &mut TestAppContext,
9284 ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
9285 let connection = StubAgentConnection::new();
9286
9287 let (thread_view, cx) =
9288 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
9289 add_to_workspace(thread_view.clone(), cx);
9290
9291 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9292 message_editor.update_in(cx, |editor, window, cx| {
9293 editor.set_text("Hello", window, cx);
9294 });
9295 thread_view.update_in(cx, |thread_view, window, cx| {
9296 thread_view.send(window, cx);
9297 });
9298
9299 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
9300 let thread = view.thread().unwrap();
9301 (thread.clone(), thread.read(cx).session_id().clone())
9302 });
9303
9304 cx.run_until_parked();
9305
9306 cx.update(|_, cx| {
9307 connection.send_update(
9308 session_id.clone(),
9309 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9310 "Response chunk".into(),
9311 )),
9312 cx,
9313 );
9314 });
9315
9316 cx.run_until_parked();
9317
9318 thread.read_with(cx, |thread, _cx| {
9319 assert_eq!(thread.status(), ThreadStatus::Generating);
9320 });
9321
9322 (
9323 GeneratingThreadSetup {
9324 thread_view,
9325 thread,
9326 message_editor,
9327 },
9328 cx,
9329 )
9330 }
9331
9332 #[gpui::test]
9333 async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
9334 init_test(cx);
9335
9336 let (setup, cx) = setup_generating_thread(cx).await;
9337
9338 let focus_handle = setup
9339 .thread_view
9340 .read_with(cx, |view, _cx| view.focus_handle.clone());
9341 cx.update(|window, cx| {
9342 window.focus(&focus_handle, cx);
9343 });
9344
9345 setup.thread_view.update_in(cx, |_, window, cx| {
9346 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
9347 });
9348
9349 cx.run_until_parked();
9350
9351 setup.thread.read_with(cx, |thread, _cx| {
9352 assert_eq!(thread.status(), ThreadStatus::Idle);
9353 });
9354 }
9355
9356 #[gpui::test]
9357 async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
9358 init_test(cx);
9359
9360 let (setup, cx) = setup_generating_thread(cx).await;
9361
9362 let editor_focus_handle = setup
9363 .message_editor
9364 .read_with(cx, |editor, cx| editor.focus_handle(cx));
9365 cx.update(|window, cx| {
9366 window.focus(&editor_focus_handle, cx);
9367 });
9368
9369 setup.message_editor.update_in(cx, |_, window, cx| {
9370 window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
9371 });
9372
9373 cx.run_until_parked();
9374
9375 setup.thread.read_with(cx, |thread, _cx| {
9376 assert_eq!(thread.status(), ThreadStatus::Idle);
9377 });
9378 }
9379
9380 #[gpui::test]
9381 async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
9382 init_test(cx);
9383
9384 let (thread_view, cx) =
9385 setup_thread_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
9386 add_to_workspace(thread_view.clone(), cx);
9387
9388 let thread = thread_view.read_with(cx, |view, _cx| view.thread().unwrap().clone());
9389
9390 thread.read_with(cx, |thread, _cx| {
9391 assert_eq!(thread.status(), ThreadStatus::Idle);
9392 });
9393
9394 let focus_handle = thread_view.read_with(cx, |view, _cx| view.focus_handle.clone());
9395 cx.update(|window, cx| {
9396 window.focus(&focus_handle, cx);
9397 });
9398
9399 thread_view.update_in(cx, |_, window, cx| {
9400 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
9401 });
9402
9403 cx.run_until_parked();
9404
9405 thread.read_with(cx, |thread, _cx| {
9406 assert_eq!(thread.status(), ThreadStatus::Idle);
9407 });
9408 }
9409
9410 #[gpui::test]
9411 async fn test_interrupt(cx: &mut TestAppContext) {
9412 init_test(cx);
9413
9414 let connection = StubAgentConnection::new();
9415
9416 let (thread_view, cx) =
9417 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
9418 add_to_workspace(thread_view.clone(), cx);
9419
9420 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9421 message_editor.update_in(cx, |editor, window, cx| {
9422 editor.set_text("Message 1", window, cx);
9423 });
9424 thread_view.update_in(cx, |thread_view, window, cx| {
9425 thread_view.send(window, cx);
9426 });
9427
9428 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
9429 let thread = view.thread().unwrap();
9430
9431 (thread.clone(), thread.read(cx).session_id().clone())
9432 });
9433
9434 cx.run_until_parked();
9435
9436 cx.update(|_, cx| {
9437 connection.send_update(
9438 session_id.clone(),
9439 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9440 "Message 1 resp".into(),
9441 )),
9442 cx,
9443 );
9444 });
9445
9446 cx.run_until_parked();
9447
9448 thread.read_with(cx, |thread, cx| {
9449 assert_eq!(
9450 thread.to_markdown(cx),
9451 indoc::indoc! {"
9452 ## User
9453
9454 Message 1
9455
9456 ## Assistant
9457
9458 Message 1 resp
9459
9460 "}
9461 )
9462 });
9463
9464 message_editor.update_in(cx, |editor, window, cx| {
9465 editor.set_text("Message 2", window, cx);
9466 });
9467 thread_view.update_in(cx, |thread_view, window, cx| {
9468 thread_view.interrupt_and_send(window, cx);
9469 });
9470
9471 cx.update(|_, cx| {
9472 // Simulate a response sent after beginning to cancel
9473 connection.send_update(
9474 session_id.clone(),
9475 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
9476 cx,
9477 );
9478 });
9479
9480 cx.run_until_parked();
9481
9482 // Last Message 1 response should appear before Message 2
9483 thread.read_with(cx, |thread, cx| {
9484 assert_eq!(
9485 thread.to_markdown(cx),
9486 indoc::indoc! {"
9487 ## User
9488
9489 Message 1
9490
9491 ## Assistant
9492
9493 Message 1 response
9494
9495 ## User
9496
9497 Message 2
9498
9499 "}
9500 )
9501 });
9502
9503 cx.update(|_, cx| {
9504 connection.send_update(
9505 session_id.clone(),
9506 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9507 "Message 2 response".into(),
9508 )),
9509 cx,
9510 );
9511 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
9512 });
9513
9514 cx.run_until_parked();
9515
9516 thread.read_with(cx, |thread, cx| {
9517 assert_eq!(
9518 thread.to_markdown(cx),
9519 indoc::indoc! {"
9520 ## User
9521
9522 Message 1
9523
9524 ## Assistant
9525
9526 Message 1 response
9527
9528 ## User
9529
9530 Message 2
9531
9532 ## Assistant
9533
9534 Message 2 response
9535
9536 "}
9537 )
9538 });
9539 }
9540
9541 #[gpui::test]
9542 async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
9543 init_test(cx);
9544
9545 let connection = StubAgentConnection::new();
9546 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
9547 acp::ContentChunk::new("Response".into()),
9548 )]);
9549
9550 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9551 add_to_workspace(thread_view.clone(), cx);
9552
9553 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9554 message_editor.update_in(cx, |editor, window, cx| {
9555 editor.set_text("Original message to edit", window, cx)
9556 });
9557 thread_view.update_in(cx, |thread_view, window, cx| thread_view.send(window, cx));
9558 cx.run_until_parked();
9559
9560 let user_message_editor = thread_view.read_with(cx, |thread_view, cx| {
9561 thread_view
9562 .entry_view_state
9563 .read(cx)
9564 .entry(0)
9565 .expect("Should have at least one entry")
9566 .message_editor()
9567 .expect("Should have message editor")
9568 .clone()
9569 });
9570
9571 cx.focus(&user_message_editor);
9572 thread_view.read_with(cx, |thread_view, _cx| {
9573 assert_eq!(thread_view.editing_message, Some(0));
9574 });
9575
9576 // Ensure to edit the focused message before proceeding otherwise, since
9577 // its content is not different from what was sent, focus will be lost.
9578 user_message_editor.update_in(cx, |editor, window, cx| {
9579 editor.set_text("Original message to edit with ", window, cx)
9580 });
9581
9582 // Create a simple buffer with some text so we can create a selection
9583 // that will then be added to the message being edited.
9584 let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
9585 (thread_view.workspace.clone(), thread_view.project.clone())
9586 });
9587 let buffer = project.update(cx, |project, cx| {
9588 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
9589 });
9590
9591 workspace
9592 .update_in(cx, |workspace, window, cx| {
9593 let editor = cx.new(|cx| {
9594 let mut editor =
9595 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
9596
9597 editor.change_selections(Default::default(), window, cx, |selections| {
9598 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
9599 });
9600
9601 editor
9602 });
9603 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
9604 })
9605 .unwrap();
9606
9607 thread_view.update_in(cx, |thread_view, window, cx| {
9608 assert_eq!(thread_view.editing_message, Some(0));
9609 thread_view.insert_selections(window, cx);
9610 });
9611
9612 user_message_editor.read_with(cx, |editor, cx| {
9613 let text = editor.editor().read(cx).text(cx);
9614 let expected_text = String::from("Original message to edit with selection ");
9615
9616 assert_eq!(text, expected_text);
9617 });
9618 }
9619
9620 #[gpui::test]
9621 async fn test_insert_selections(cx: &mut TestAppContext) {
9622 init_test(cx);
9623
9624 let connection = StubAgentConnection::new();
9625 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
9626 acp::ContentChunk::new("Response".into()),
9627 )]);
9628
9629 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9630 add_to_workspace(thread_view.clone(), cx);
9631
9632 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9633 message_editor.update_in(cx, |editor, window, cx| {
9634 editor.set_text("Can you review this snippet ", window, cx)
9635 });
9636
9637 // Create a simple buffer with some text so we can create a selection
9638 // that will then be added to the message being edited.
9639 let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
9640 (thread_view.workspace.clone(), thread_view.project.clone())
9641 });
9642 let buffer = project.update(cx, |project, cx| {
9643 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
9644 });
9645
9646 workspace
9647 .update_in(cx, |workspace, window, cx| {
9648 let editor = cx.new(|cx| {
9649 let mut editor =
9650 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
9651
9652 editor.change_selections(Default::default(), window, cx, |selections| {
9653 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
9654 });
9655
9656 editor
9657 });
9658 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
9659 })
9660 .unwrap();
9661
9662 thread_view.update_in(cx, |thread_view, window, cx| {
9663 assert_eq!(thread_view.editing_message, None);
9664 thread_view.insert_selections(window, cx);
9665 });
9666
9667 thread_view.read_with(cx, |thread_view, cx| {
9668 let text = thread_view.message_editor.read(cx).text(cx);
9669 let expected_txt = String::from("Can you review this snippet selection ");
9670
9671 assert_eq!(text, expected_txt);
9672 })
9673 }
9674
9675 #[gpui::test]
9676 async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
9677 init_test(cx);
9678
9679 let tool_call_id = acp::ToolCallId::new("terminal-1");
9680 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
9681 .kind(acp::ToolKind::Edit);
9682
9683 let permission_options = ToolPermissionContext::new("terminal", "cargo build --release")
9684 .build_permission_options();
9685
9686 let connection =
9687 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9688 tool_call_id.clone(),
9689 permission_options,
9690 )]));
9691
9692 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9693
9694 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9695
9696 // Disable notifications to avoid popup windows
9697 cx.update(|_window, cx| {
9698 AgentSettings::override_global(
9699 AgentSettings {
9700 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9701 ..AgentSettings::get_global(cx).clone()
9702 },
9703 cx,
9704 );
9705 });
9706
9707 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9708 message_editor.update_in(cx, |editor, window, cx| {
9709 editor.set_text("Run cargo build", window, cx);
9710 });
9711
9712 thread_view.update_in(cx, |thread_view, window, cx| {
9713 thread_view.send(window, cx);
9714 });
9715
9716 cx.run_until_parked();
9717
9718 // Verify the tool call is in WaitingForConfirmation state with the expected options
9719 thread_view.read_with(cx, |thread_view, cx| {
9720 let thread = thread_view.thread().expect("Thread should exist");
9721 let thread = thread.read(cx);
9722
9723 let tool_call = thread.entries().iter().find_map(|entry| {
9724 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9725 Some(call)
9726 } else {
9727 None
9728 }
9729 });
9730
9731 assert!(tool_call.is_some(), "Expected a tool call entry");
9732 let tool_call = tool_call.unwrap();
9733
9734 // Verify it's waiting for confirmation
9735 assert!(
9736 matches!(
9737 tool_call.status,
9738 acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
9739 ),
9740 "Expected WaitingForConfirmation status, got {:?}",
9741 tool_call.status
9742 );
9743
9744 // Verify the options count (granularity options only, no separate Deny option)
9745 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9746 &tool_call.status
9747 {
9748 assert_eq!(
9749 options.len(),
9750 3,
9751 "Expected 3 permission options (granularity only)"
9752 );
9753
9754 // Verify specific button labels (now using neutral names)
9755 let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
9756 assert!(
9757 labels.contains(&"Always for terminal"),
9758 "Missing 'Always for terminal' option"
9759 );
9760 assert!(
9761 labels.contains(&"Always for `cargo` commands"),
9762 "Missing pattern option"
9763 );
9764 assert!(
9765 labels.contains(&"Only this time"),
9766 "Missing 'Only this time' option"
9767 );
9768 }
9769 });
9770 }
9771
9772 #[gpui::test]
9773 async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
9774 init_test(cx);
9775
9776 let tool_call_id = acp::ToolCallId::new("edit-file-1");
9777 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
9778 .kind(acp::ToolKind::Edit);
9779
9780 let permission_options =
9781 ToolPermissionContext::new("edit_file", "src/main.rs").build_permission_options();
9782
9783 let connection =
9784 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9785 tool_call_id.clone(),
9786 permission_options,
9787 )]));
9788
9789 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9790
9791 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9792
9793 // Disable notifications
9794 cx.update(|_window, cx| {
9795 AgentSettings::override_global(
9796 AgentSettings {
9797 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9798 ..AgentSettings::get_global(cx).clone()
9799 },
9800 cx,
9801 );
9802 });
9803
9804 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9805 message_editor.update_in(cx, |editor, window, cx| {
9806 editor.set_text("Edit the main file", window, cx);
9807 });
9808
9809 thread_view.update_in(cx, |thread_view, window, cx| {
9810 thread_view.send(window, cx);
9811 });
9812
9813 cx.run_until_parked();
9814
9815 // Verify the options
9816 thread_view.read_with(cx, |thread_view, cx| {
9817 let thread = thread_view.thread().expect("Thread should exist");
9818 let thread = thread.read(cx);
9819
9820 let tool_call = thread.entries().iter().find_map(|entry| {
9821 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9822 Some(call)
9823 } else {
9824 None
9825 }
9826 });
9827
9828 assert!(tool_call.is_some(), "Expected a tool call entry");
9829 let tool_call = tool_call.unwrap();
9830
9831 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9832 &tool_call.status
9833 {
9834 let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
9835 assert!(
9836 labels.contains(&"Always for edit file"),
9837 "Missing 'Always for edit file' option"
9838 );
9839 assert!(
9840 labels.contains(&"Always for `src/`"),
9841 "Missing path pattern option"
9842 );
9843 } else {
9844 panic!("Expected WaitingForConfirmation status");
9845 }
9846 });
9847 }
9848
9849 #[gpui::test]
9850 async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
9851 init_test(cx);
9852
9853 let tool_call_id = acp::ToolCallId::new("fetch-1");
9854 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
9855 .kind(acp::ToolKind::Fetch);
9856
9857 let permission_options =
9858 ToolPermissionContext::new("fetch", "https://docs.rs/gpui").build_permission_options();
9859
9860 let connection =
9861 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9862 tool_call_id.clone(),
9863 permission_options,
9864 )]));
9865
9866 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9867
9868 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9869
9870 // Disable notifications
9871 cx.update(|_window, cx| {
9872 AgentSettings::override_global(
9873 AgentSettings {
9874 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9875 ..AgentSettings::get_global(cx).clone()
9876 },
9877 cx,
9878 );
9879 });
9880
9881 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9882 message_editor.update_in(cx, |editor, window, cx| {
9883 editor.set_text("Fetch the docs", window, cx);
9884 });
9885
9886 thread_view.update_in(cx, |thread_view, window, cx| {
9887 thread_view.send(window, cx);
9888 });
9889
9890 cx.run_until_parked();
9891
9892 // Verify the options
9893 thread_view.read_with(cx, |thread_view, cx| {
9894 let thread = thread_view.thread().expect("Thread should exist");
9895 let thread = thread.read(cx);
9896
9897 let tool_call = thread.entries().iter().find_map(|entry| {
9898 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9899 Some(call)
9900 } else {
9901 None
9902 }
9903 });
9904
9905 assert!(tool_call.is_some(), "Expected a tool call entry");
9906 let tool_call = tool_call.unwrap();
9907
9908 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9909 &tool_call.status
9910 {
9911 let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
9912 assert!(
9913 labels.contains(&"Always for fetch"),
9914 "Missing 'Always for fetch' option"
9915 );
9916 assert!(
9917 labels.contains(&"Always for `docs.rs`"),
9918 "Missing domain pattern option"
9919 );
9920 } else {
9921 panic!("Expected WaitingForConfirmation status");
9922 }
9923 });
9924 }
9925
9926 #[gpui::test]
9927 async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
9928 init_test(cx);
9929
9930 let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
9931 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
9932 .kind(acp::ToolKind::Edit);
9933
9934 // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
9935 let permission_options = ToolPermissionContext::new("terminal", "./deploy.sh --production")
9936 .build_permission_options();
9937
9938 let connection =
9939 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9940 tool_call_id.clone(),
9941 permission_options,
9942 )]));
9943
9944 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9945
9946 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
9947
9948 // Disable notifications
9949 cx.update(|_window, cx| {
9950 AgentSettings::override_global(
9951 AgentSettings {
9952 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9953 ..AgentSettings::get_global(cx).clone()
9954 },
9955 cx,
9956 );
9957 });
9958
9959 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
9960 message_editor.update_in(cx, |editor, window, cx| {
9961 editor.set_text("Run the deploy script", window, cx);
9962 });
9963
9964 thread_view.update_in(cx, |thread_view, window, cx| {
9965 thread_view.send(window, cx);
9966 });
9967
9968 cx.run_until_parked();
9969
9970 // Verify only 2 options (no pattern button when command doesn't match pattern)
9971 thread_view.read_with(cx, |thread_view, cx| {
9972 let thread = thread_view.thread().expect("Thread should exist");
9973 let thread = thread.read(cx);
9974
9975 let tool_call = thread.entries().iter().find_map(|entry| {
9976 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9977 Some(call)
9978 } else {
9979 None
9980 }
9981 });
9982
9983 assert!(tool_call.is_some(), "Expected a tool call entry");
9984 let tool_call = tool_call.unwrap();
9985
9986 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9987 &tool_call.status
9988 {
9989 assert_eq!(
9990 options.len(),
9991 2,
9992 "Expected 2 permission options (no pattern option)"
9993 );
9994
9995 let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
9996 assert!(
9997 labels.contains(&"Always for terminal"),
9998 "Missing 'Always for terminal' option"
9999 );
10000 assert!(
10001 labels.contains(&"Only this time"),
10002 "Missing 'Only this time' option"
10003 );
10004 // Should NOT contain a pattern option
10005 assert!(
10006 !labels.iter().any(|l| l.contains("commands")),
10007 "Should not have pattern option"
10008 );
10009 } else {
10010 panic!("Expected WaitingForConfirmation status");
10011 }
10012 });
10013 }
10014
10015 #[gpui::test]
10016 async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
10017 init_test(cx);
10018
10019 let tool_call_id = acp::ToolCallId::new("action-test-1");
10020 let tool_call =
10021 acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
10022
10023 let permission_options =
10024 ToolPermissionContext::new("terminal", "cargo test").build_permission_options();
10025
10026 let connection =
10027 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
10028 tool_call_id.clone(),
10029 permission_options,
10030 )]));
10031
10032 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
10033
10034 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
10035 add_to_workspace(thread_view.clone(), cx);
10036
10037 cx.update(|_window, cx| {
10038 AgentSettings::override_global(
10039 AgentSettings {
10040 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
10041 ..AgentSettings::get_global(cx).clone()
10042 },
10043 cx,
10044 );
10045 });
10046
10047 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
10048 message_editor.update_in(cx, |editor, window, cx| {
10049 editor.set_text("Run tests", window, cx);
10050 });
10051
10052 thread_view.update_in(cx, |thread_view, window, cx| {
10053 thread_view.send(window, cx);
10054 });
10055
10056 cx.run_until_parked();
10057
10058 // Verify tool call is waiting for confirmation
10059 thread_view.read_with(cx, |thread_view, cx| {
10060 let thread = thread_view.thread().expect("Thread should exist");
10061 let thread = thread.read(cx);
10062 let tool_call = thread.first_tool_awaiting_confirmation();
10063 assert!(
10064 tool_call.is_some(),
10065 "Expected a tool call waiting for confirmation"
10066 );
10067 });
10068
10069 // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
10070 thread_view.update_in(cx, |_, window, cx| {
10071 window.dispatch_action(
10072 crate::AuthorizeToolCall {
10073 tool_call_id: "action-test-1".to_string(),
10074 option_id: "allow".to_string(),
10075 option_kind: "AllowOnce".to_string(),
10076 }
10077 .boxed_clone(),
10078 cx,
10079 );
10080 });
10081
10082 cx.run_until_parked();
10083
10084 // Verify tool call is no longer waiting for confirmation (was authorized)
10085 thread_view.read_with(cx, |thread_view, cx| {
10086 let thread = thread_view.thread().expect("Thread should exist");
10087 let thread = thread.read(cx);
10088 let tool_call = thread.first_tool_awaiting_confirmation();
10089 assert!(
10090 tool_call.is_none(),
10091 "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
10092 );
10093 });
10094 }
10095
10096 #[gpui::test]
10097 async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
10098 init_test(cx);
10099
10100 let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
10101 let tool_call =
10102 acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
10103
10104 let permission_options =
10105 ToolPermissionContext::new("terminal", "npm install").build_permission_options();
10106
10107 let connection =
10108 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
10109 tool_call_id.clone(),
10110 permission_options.clone(),
10111 )]));
10112
10113 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
10114
10115 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
10116 add_to_workspace(thread_view.clone(), cx);
10117
10118 cx.update(|_window, cx| {
10119 AgentSettings::override_global(
10120 AgentSettings {
10121 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
10122 ..AgentSettings::get_global(cx).clone()
10123 },
10124 cx,
10125 );
10126 });
10127
10128 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
10129 message_editor.update_in(cx, |editor, window, cx| {
10130 editor.set_text("Install dependencies", window, cx);
10131 });
10132
10133 thread_view.update_in(cx, |thread_view, window, cx| {
10134 thread_view.send(window, cx);
10135 });
10136
10137 cx.run_until_parked();
10138
10139 // Find the pattern option ID
10140 let pattern_option = permission_options
10141 .iter()
10142 .find(|o| o.option_id.0.starts_with("always_pattern:"))
10143 .expect("Should have a pattern option for npm command");
10144
10145 // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
10146 thread_view.update_in(cx, |_, window, cx| {
10147 window.dispatch_action(
10148 crate::AuthorizeToolCall {
10149 tool_call_id: "pattern-action-test-1".to_string(),
10150 option_id: pattern_option.option_id.0.to_string(),
10151 option_kind: "AllowAlways".to_string(),
10152 }
10153 .boxed_clone(),
10154 cx,
10155 );
10156 });
10157
10158 cx.run_until_parked();
10159
10160 // Verify tool call was authorized
10161 thread_view.read_with(cx, |thread_view, cx| {
10162 let thread = thread_view.thread().expect("Thread should exist");
10163 let thread = thread.read(cx);
10164 let tool_call = thread.first_tool_awaiting_confirmation();
10165 assert!(
10166 tool_call.is_none(),
10167 "Tool call should be authorized after selecting pattern option"
10168 );
10169 });
10170 }
10171
10172 #[gpui::test]
10173 async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
10174 init_test(cx);
10175
10176 let tool_call_id = acp::ToolCallId::new("granularity-test-1");
10177 let tool_call =
10178 acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
10179
10180 let permission_options =
10181 ToolPermissionContext::new("terminal", "cargo build").build_permission_options();
10182
10183 let connection =
10184 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
10185 tool_call_id.clone(),
10186 permission_options.clone(),
10187 )]));
10188
10189 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
10190
10191 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
10192 add_to_workspace(thread_view.clone(), cx);
10193
10194 cx.update(|_window, cx| {
10195 AgentSettings::override_global(
10196 AgentSettings {
10197 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
10198 ..AgentSettings::get_global(cx).clone()
10199 },
10200 cx,
10201 );
10202 });
10203
10204 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
10205 message_editor.update_in(cx, |editor, window, cx| {
10206 editor.set_text("Build the project", window, cx);
10207 });
10208
10209 thread_view.update_in(cx, |thread_view, window, cx| {
10210 thread_view.send(window, cx);
10211 });
10212
10213 cx.run_until_parked();
10214
10215 // Verify default granularity is the last option (index 2 = "Only this time")
10216 thread_view.read_with(cx, |thread_view, _cx| {
10217 let selected = thread_view
10218 .selected_permission_granularity
10219 .get(&tool_call_id);
10220 assert!(
10221 selected.is_none(),
10222 "Should have no selection initially (defaults to last)"
10223 );
10224 });
10225
10226 // Select the first option (index 0 = "Always for terminal")
10227 thread_view.update_in(cx, |_, window, cx| {
10228 window.dispatch_action(
10229 crate::SelectPermissionGranularity {
10230 tool_call_id: "granularity-test-1".to_string(),
10231 index: 0,
10232 }
10233 .boxed_clone(),
10234 cx,
10235 );
10236 });
10237
10238 cx.run_until_parked();
10239
10240 // Verify the selection was updated
10241 thread_view.read_with(cx, |thread_view, _cx| {
10242 let selected = thread_view
10243 .selected_permission_granularity
10244 .get(&tool_call_id);
10245 assert_eq!(selected, Some(&0), "Should have selected index 0");
10246 });
10247 }
10248
10249 #[gpui::test]
10250 async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
10251 init_test(cx);
10252
10253 let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
10254 let tool_call =
10255 acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
10256
10257 let permission_options =
10258 ToolPermissionContext::new("terminal", "npm install").build_permission_options();
10259
10260 // Verify we have the expected options
10261 assert_eq!(permission_options.len(), 3);
10262 assert!(
10263 permission_options[0]
10264 .option_id
10265 .0
10266 .contains("always:terminal")
10267 );
10268 assert!(
10269 permission_options[1]
10270 .option_id
10271 .0
10272 .contains("always_pattern:terminal")
10273 );
10274 assert_eq!(permission_options[2].option_id.0.as_ref(), "once");
10275
10276 let connection =
10277 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
10278 tool_call_id.clone(),
10279 permission_options.clone(),
10280 )]));
10281
10282 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
10283
10284 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
10285 add_to_workspace(thread_view.clone(), cx);
10286
10287 cx.update(|_window, cx| {
10288 AgentSettings::override_global(
10289 AgentSettings {
10290 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
10291 ..AgentSettings::get_global(cx).clone()
10292 },
10293 cx,
10294 );
10295 });
10296
10297 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
10298 message_editor.update_in(cx, |editor, window, cx| {
10299 editor.set_text("Install dependencies", window, cx);
10300 });
10301
10302 thread_view.update_in(cx, |thread_view, window, cx| {
10303 thread_view.send(window, cx);
10304 });
10305
10306 cx.run_until_parked();
10307
10308 // Select the pattern option (index 1 = "Always for `npm` commands")
10309 thread_view.update_in(cx, |_, window, cx| {
10310 window.dispatch_action(
10311 crate::SelectPermissionGranularity {
10312 tool_call_id: "allow-granularity-test-1".to_string(),
10313 index: 1,
10314 }
10315 .boxed_clone(),
10316 cx,
10317 );
10318 });
10319
10320 cx.run_until_parked();
10321
10322 // Simulate clicking the Allow button by dispatching AllowOnce action
10323 // which should use the selected granularity
10324 thread_view.update_in(cx, |thread_view, window, cx| {
10325 thread_view.allow_once(&AllowOnce, window, cx);
10326 });
10327
10328 cx.run_until_parked();
10329
10330 // Verify tool call was authorized
10331 thread_view.read_with(cx, |thread_view, cx| {
10332 let thread = thread_view.thread().expect("Thread should exist");
10333 let thread = thread.read(cx);
10334 let tool_call = thread.first_tool_awaiting_confirmation();
10335 assert!(
10336 tool_call.is_none(),
10337 "Tool call should be authorized after Allow with pattern granularity"
10338 );
10339 });
10340 }
10341
10342 #[gpui::test]
10343 async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
10344 init_test(cx);
10345
10346 let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
10347 let tool_call =
10348 acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
10349
10350 let permission_options =
10351 ToolPermissionContext::new("terminal", "git push").build_permission_options();
10352
10353 let connection =
10354 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
10355 tool_call_id.clone(),
10356 permission_options.clone(),
10357 )]));
10358
10359 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
10360
10361 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
10362 add_to_workspace(thread_view.clone(), cx);
10363
10364 cx.update(|_window, cx| {
10365 AgentSettings::override_global(
10366 AgentSettings {
10367 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
10368 ..AgentSettings::get_global(cx).clone()
10369 },
10370 cx,
10371 );
10372 });
10373
10374 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
10375 message_editor.update_in(cx, |editor, window, cx| {
10376 editor.set_text("Push changes", window, cx);
10377 });
10378
10379 thread_view.update_in(cx, |thread_view, window, cx| {
10380 thread_view.send(window, cx);
10381 });
10382
10383 cx.run_until_parked();
10384
10385 // Use default granularity (last option = "Only this time")
10386 // Simulate clicking the Deny button
10387 thread_view.update_in(cx, |thread_view, window, cx| {
10388 thread_view.reject_once(&RejectOnce, window, cx);
10389 });
10390
10391 cx.run_until_parked();
10392
10393 // Verify tool call was rejected (no longer waiting for confirmation)
10394 thread_view.read_with(cx, |thread_view, cx| {
10395 let thread = thread_view.thread().expect("Thread should exist");
10396 let thread = thread.read(cx);
10397 let tool_call = thread.first_tool_awaiting_confirmation();
10398 assert!(
10399 tool_call.is_none(),
10400 "Tool call should be rejected after Deny"
10401 );
10402 });
10403 }
10404
10405 #[gpui::test]
10406 async fn test_option_id_transformation_for_allow() {
10407 // Test the option_id transformation logic directly
10408 // "once" -> "allow"
10409 // "always:terminal" -> "always_allow:terminal"
10410 // "always_pattern:terminal:^cargo\s" -> "always_allow_pattern:terminal:^cargo\s"
10411
10412 let test_cases = vec![
10413 ("once", "allow"),
10414 ("always:terminal", "always_allow:terminal"),
10415 (
10416 "always_pattern:terminal:^cargo\\s",
10417 "always_allow_pattern:terminal:^cargo\\s",
10418 ),
10419 ("always:fetch", "always_allow:fetch"),
10420 (
10421 "always_pattern:fetch:^https?://docs\\.rs",
10422 "always_allow_pattern:fetch:^https?://docs\\.rs",
10423 ),
10424 ];
10425
10426 for (input, expected) in test_cases {
10427 let result = if input == "once" {
10428 "allow".to_string()
10429 } else if let Some(rest) = input.strip_prefix("always:") {
10430 format!("always_allow:{}", rest)
10431 } else if let Some(rest) = input.strip_prefix("always_pattern:") {
10432 format!("always_allow_pattern:{}", rest)
10433 } else {
10434 input.to_string()
10435 };
10436 assert_eq!(result, expected, "Failed for input: {}", input);
10437 }
10438 }
10439
10440 #[gpui::test]
10441 async fn test_option_id_transformation_for_deny() {
10442 // Test the option_id transformation logic for deny
10443 // "once" -> "deny"
10444 // "always:terminal" -> "always_deny:terminal"
10445 // "always_pattern:terminal:^cargo\s" -> "always_deny_pattern:terminal:^cargo\s"
10446
10447 let test_cases = vec![
10448 ("once", "deny"),
10449 ("always:terminal", "always_deny:terminal"),
10450 (
10451 "always_pattern:terminal:^cargo\\s",
10452 "always_deny_pattern:terminal:^cargo\\s",
10453 ),
10454 ("always:fetch", "always_deny:fetch"),
10455 (
10456 "always_pattern:fetch:^https?://docs\\.rs",
10457 "always_deny_pattern:fetch:^https?://docs\\.rs",
10458 ),
10459 ];
10460
10461 for (input, expected) in test_cases {
10462 let result = if input == "once" {
10463 "deny".to_string()
10464 } else if let Some(rest) = input.strip_prefix("always:") {
10465 format!("always_deny:{}", rest)
10466 } else if let Some(rest) = input.strip_prefix("always_pattern:") {
10467 format!("always_deny_pattern:{}", rest)
10468 } else {
10469 input.replace("allow", "deny")
10470 };
10471 assert_eq!(result, expected, "Failed for input: {}", input);
10472 }
10473 }
10474}