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