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