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