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