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