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