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