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