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