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