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