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