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