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