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