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