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