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