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