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