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