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 && self
3820 .thread()
3821 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
3822 {
3823 let feedback = self.thread_feedback.feedback;
3824 container = container.child(
3825 div().visible_on_hover("thread-controls-container").child(
3826 Label::new(
3827 match feedback {
3828 Some(ThreadFeedback::Positive) => "Thanks for your feedback!",
3829 Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.",
3830 None => "Rating the thread sends all of your current conversation to the Zed team.",
3831 }
3832 )
3833 .color(Color::Muted)
3834 .size(LabelSize::XSmall)
3835 .truncate(),
3836 ),
3837 ).child(
3838 h_flex()
3839 .child(
3840 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
3841 .shape(ui::IconButtonShape::Square)
3842 .icon_size(IconSize::Small)
3843 .icon_color(match feedback {
3844 Some(ThreadFeedback::Positive) => Color::Accent,
3845 _ => Color::Ignored,
3846 })
3847 .tooltip(Tooltip::text("Helpful Response"))
3848 .on_click(cx.listener(move |this, _, window, cx| {
3849 this.handle_feedback_click(
3850 ThreadFeedback::Positive,
3851 window,
3852 cx,
3853 );
3854 })),
3855 )
3856 .child(
3857 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
3858 .shape(ui::IconButtonShape::Square)
3859 .icon_size(IconSize::Small)
3860 .icon_color(match feedback {
3861 Some(ThreadFeedback::Negative) => Color::Accent,
3862 _ => Color::Ignored,
3863 })
3864 .tooltip(Tooltip::text("Not Helpful"))
3865 .on_click(cx.listener(move |this, _, window, cx| {
3866 this.handle_feedback_click(
3867 ThreadFeedback::Negative,
3868 window,
3869 cx,
3870 );
3871 })),
3872 )
3873 )
3874 }
3875
3876 container.child(open_as_markdown).child(scroll_to_top)
3877 }
3878
3879 fn render_feedback_feedback_editor(
3880 editor: Entity<Editor>,
3881 window: &mut Window,
3882 cx: &Context<Self>,
3883 ) -> Div {
3884 let focus_handle = editor.focus_handle(cx);
3885 v_flex()
3886 .key_context("AgentFeedbackMessageEditor")
3887 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
3888 this.thread_feedback.dismiss_comments();
3889 cx.notify();
3890 }))
3891 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
3892 this.submit_feedback_message(cx);
3893 }))
3894 .mb_2()
3895 .mx_4()
3896 .p_2()
3897 .rounded_md()
3898 .border_1()
3899 .border_color(cx.theme().colors().border)
3900 .bg(cx.theme().colors().editor_background)
3901 .child(editor)
3902 .child(
3903 h_flex()
3904 .gap_1()
3905 .justify_end()
3906 .child(
3907 Button::new("dismiss-feedback-message", "Cancel")
3908 .label_size(LabelSize::Small)
3909 .key_binding(
3910 KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx)
3911 .map(|kb| kb.size(rems_from_px(10.))),
3912 )
3913 .on_click(cx.listener(move |this, _, _window, cx| {
3914 this.thread_feedback.dismiss_comments();
3915 cx.notify();
3916 })),
3917 )
3918 .child(
3919 Button::new("submit-feedback-message", "Share Feedback")
3920 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3921 .label_size(LabelSize::Small)
3922 .key_binding(
3923 KeyBinding::for_action_in(
3924 &menu::Confirm,
3925 &focus_handle,
3926 window,
3927 cx,
3928 )
3929 .map(|kb| kb.size(rems_from_px(10.))),
3930 )
3931 .on_click(cx.listener(move |this, _, _window, cx| {
3932 this.submit_feedback_message(cx);
3933 })),
3934 ),
3935 )
3936 }
3937
3938 fn handle_feedback_click(
3939 &mut self,
3940 feedback: ThreadFeedback,
3941 window: &mut Window,
3942 cx: &mut Context<Self>,
3943 ) {
3944 let Some(thread) = self.thread().cloned() else {
3945 return;
3946 };
3947
3948 self.thread_feedback.submit(thread, feedback, window, cx);
3949 cx.notify();
3950 }
3951
3952 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
3953 let Some(thread) = self.thread().cloned() else {
3954 return;
3955 };
3956
3957 self.thread_feedback.submit_comments(thread, cx);
3958 cx.notify();
3959 }
3960
3961 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
3962 div()
3963 .id("acp-thread-scrollbar")
3964 .occlude()
3965 .on_mouse_move(cx.listener(|_, _, _, cx| {
3966 cx.notify();
3967 cx.stop_propagation()
3968 }))
3969 .on_hover(|_, _, cx| {
3970 cx.stop_propagation();
3971 })
3972 .on_any_mouse_down(|_, _, cx| {
3973 cx.stop_propagation();
3974 })
3975 .on_mouse_up(
3976 MouseButton::Left,
3977 cx.listener(|_, _, _, cx| {
3978 cx.stop_propagation();
3979 }),
3980 )
3981 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3982 cx.notify();
3983 }))
3984 .h_full()
3985 .absolute()
3986 .right_1()
3987 .top_1()
3988 .bottom_0()
3989 .w(px(12.))
3990 .cursor_default()
3991 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
3992 }
3993
3994 fn render_token_limit_callout(
3995 &self,
3996 line_height: Pixels,
3997 cx: &mut Context<Self>,
3998 ) -> Option<Callout> {
3999 let token_usage = self.thread()?.read(cx).token_usage()?;
4000 let ratio = token_usage.ratio();
4001
4002 let (severity, title) = match ratio {
4003 acp_thread::TokenUsageRatio::Normal => return None,
4004 acp_thread::TokenUsageRatio::Warning => {
4005 (Severity::Warning, "Thread reaching the token limit soon")
4006 }
4007 acp_thread::TokenUsageRatio::Exceeded => {
4008 (Severity::Error, "Thread reached the token limit")
4009 }
4010 };
4011
4012 let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
4013 thread.read(cx).completion_mode() == CompletionMode::Normal
4014 && thread
4015 .read(cx)
4016 .model()
4017 .is_some_and(|model| model.supports_burn_mode())
4018 });
4019
4020 let description = if burn_mode_available {
4021 "To continue, start a new thread from a summary or turn Burn Mode on."
4022 } else {
4023 "To continue, start a new thread from a summary."
4024 };
4025
4026 Some(
4027 Callout::new()
4028 .severity(severity)
4029 .line_height(line_height)
4030 .title(title)
4031 .description(description)
4032 .actions_slot(
4033 h_flex()
4034 .gap_0p5()
4035 .child(
4036 Button::new("start-new-thread", "Start New Thread")
4037 .label_size(LabelSize::Small)
4038 .on_click(cx.listener(|this, _, window, cx| {
4039 let Some(thread) = this.thread() else {
4040 return;
4041 };
4042 let session_id = thread.read(cx).session_id().clone();
4043 window.dispatch_action(
4044 crate::NewNativeAgentThreadFromSummary {
4045 from_session_id: session_id,
4046 }
4047 .boxed_clone(),
4048 cx,
4049 );
4050 })),
4051 )
4052 .when(burn_mode_available, |this| {
4053 this.child(
4054 IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
4055 .icon_size(IconSize::XSmall)
4056 .on_click(cx.listener(|this, _event, window, cx| {
4057 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4058 })),
4059 )
4060 }),
4061 ),
4062 )
4063 }
4064
4065 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
4066 if !self.is_using_zed_ai_models(cx) {
4067 return None;
4068 }
4069
4070 let user_store = self.project.read(cx).user_store().read(cx);
4071 if user_store.is_usage_based_billing_enabled() {
4072 return None;
4073 }
4074
4075 let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
4076
4077 let usage = user_store.model_request_usage()?;
4078
4079 Some(
4080 div()
4081 .child(UsageCallout::new(plan, usage))
4082 .line_height(line_height),
4083 )
4084 }
4085
4086 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4087 self.entry_view_state.update(cx, |entry_view_state, cx| {
4088 entry_view_state.settings_changed(cx);
4089 });
4090 }
4091
4092 pub(crate) fn insert_dragged_files(
4093 &self,
4094 paths: Vec<project::ProjectPath>,
4095 added_worktrees: Vec<Entity<project::Worktree>>,
4096 window: &mut Window,
4097 cx: &mut Context<Self>,
4098 ) {
4099 self.message_editor.update(cx, |message_editor, cx| {
4100 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
4101 })
4102 }
4103
4104 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
4105 self.message_editor.update(cx, |message_editor, cx| {
4106 message_editor.insert_selections(window, cx);
4107 })
4108 }
4109
4110 fn render_thread_retry_status_callout(
4111 &self,
4112 _window: &mut Window,
4113 _cx: &mut Context<Self>,
4114 ) -> Option<Callout> {
4115 let state = self.thread_retry_status.as_ref()?;
4116
4117 let next_attempt_in = state
4118 .duration
4119 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
4120 if next_attempt_in.is_zero() {
4121 return None;
4122 }
4123
4124 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
4125
4126 let retry_message = if state.max_attempts == 1 {
4127 if next_attempt_in_secs == 1 {
4128 "Retrying. Next attempt in 1 second.".to_string()
4129 } else {
4130 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
4131 }
4132 } else if next_attempt_in_secs == 1 {
4133 format!(
4134 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
4135 state.attempt, state.max_attempts,
4136 )
4137 } else {
4138 format!(
4139 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
4140 state.attempt, state.max_attempts,
4141 )
4142 };
4143
4144 Some(
4145 Callout::new()
4146 .severity(Severity::Warning)
4147 .title(state.last_error.clone())
4148 .description(retry_message),
4149 )
4150 }
4151
4152 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
4153 let content = match self.thread_error.as_ref()? {
4154 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
4155 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
4156 ThreadError::ModelRequestLimitReached(plan) => {
4157 self.render_model_request_limit_reached_error(*plan, cx)
4158 }
4159 ThreadError::ToolUseLimitReached => {
4160 self.render_tool_use_limit_reached_error(window, cx)?
4161 }
4162 };
4163
4164 Some(div().child(content))
4165 }
4166
4167 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
4168 Callout::new()
4169 .severity(Severity::Error)
4170 .title("Error")
4171 .description(error.clone())
4172 .actions_slot(self.create_copy_button(error.to_string()))
4173 .dismiss_action(self.dismiss_error_button(cx))
4174 }
4175
4176 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
4177 const ERROR_MESSAGE: &str =
4178 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
4179
4180 Callout::new()
4181 .severity(Severity::Error)
4182 .title("Free Usage Exceeded")
4183 .description(ERROR_MESSAGE)
4184 .actions_slot(
4185 h_flex()
4186 .gap_0p5()
4187 .child(self.upgrade_button(cx))
4188 .child(self.create_copy_button(ERROR_MESSAGE)),
4189 )
4190 .dismiss_action(self.dismiss_error_button(cx))
4191 }
4192
4193 fn render_model_request_limit_reached_error(
4194 &self,
4195 plan: cloud_llm_client::Plan,
4196 cx: &mut Context<Self>,
4197 ) -> Callout {
4198 let error_message = match plan {
4199 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
4200 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
4201 "Upgrade to Zed Pro for more prompts."
4202 }
4203 };
4204
4205 Callout::new()
4206 .severity(Severity::Error)
4207 .title("Model Prompt Limit Reached")
4208 .description(error_message)
4209 .actions_slot(
4210 h_flex()
4211 .gap_0p5()
4212 .child(self.upgrade_button(cx))
4213 .child(self.create_copy_button(error_message)),
4214 )
4215 .dismiss_action(self.dismiss_error_button(cx))
4216 }
4217
4218 fn render_tool_use_limit_reached_error(
4219 &self,
4220 window: &mut Window,
4221 cx: &mut Context<Self>,
4222 ) -> Option<Callout> {
4223 let thread = self.as_native_thread(cx)?;
4224 let supports_burn_mode = thread
4225 .read(cx)
4226 .model()
4227 .is_some_and(|model| model.supports_burn_mode());
4228
4229 let focus_handle = self.focus_handle(cx);
4230
4231 Some(
4232 Callout::new()
4233 .icon(IconName::Info)
4234 .title("Consecutive tool use limit reached.")
4235 .actions_slot(
4236 h_flex()
4237 .gap_0p5()
4238 .when(supports_burn_mode, |this| {
4239 this.child(
4240 Button::new("continue-burn-mode", "Continue with Burn Mode")
4241 .style(ButtonStyle::Filled)
4242 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4243 .layer(ElevationIndex::ModalSurface)
4244 .label_size(LabelSize::Small)
4245 .key_binding(
4246 KeyBinding::for_action_in(
4247 &ContinueWithBurnMode,
4248 &focus_handle,
4249 window,
4250 cx,
4251 )
4252 .map(|kb| kb.size(rems_from_px(10.))),
4253 )
4254 .tooltip(Tooltip::text(
4255 "Enable Burn Mode for unlimited tool use.",
4256 ))
4257 .on_click({
4258 cx.listener(move |this, _, _window, cx| {
4259 thread.update(cx, |thread, cx| {
4260 thread
4261 .set_completion_mode(CompletionMode::Burn, cx);
4262 });
4263 this.resume_chat(cx);
4264 })
4265 }),
4266 )
4267 })
4268 .child(
4269 Button::new("continue-conversation", "Continue")
4270 .layer(ElevationIndex::ModalSurface)
4271 .label_size(LabelSize::Small)
4272 .key_binding(
4273 KeyBinding::for_action_in(
4274 &ContinueThread,
4275 &focus_handle,
4276 window,
4277 cx,
4278 )
4279 .map(|kb| kb.size(rems_from_px(10.))),
4280 )
4281 .on_click(cx.listener(|this, _, _window, cx| {
4282 this.resume_chat(cx);
4283 })),
4284 ),
4285 ),
4286 )
4287 }
4288
4289 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
4290 let message = message.into();
4291
4292 IconButton::new("copy", IconName::Copy)
4293 .icon_size(IconSize::Small)
4294 .icon_color(Color::Muted)
4295 .tooltip(Tooltip::text("Copy Error Message"))
4296 .on_click(move |_, _, cx| {
4297 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
4298 })
4299 }
4300
4301 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4302 IconButton::new("dismiss", IconName::Close)
4303 .icon_size(IconSize::Small)
4304 .icon_color(Color::Muted)
4305 .tooltip(Tooltip::text("Dismiss Error"))
4306 .on_click(cx.listener({
4307 move |this, _, _, cx| {
4308 this.clear_thread_error(cx);
4309 cx.notify();
4310 }
4311 }))
4312 }
4313
4314 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4315 Button::new("upgrade", "Upgrade")
4316 .label_size(LabelSize::Small)
4317 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4318 .on_click(cx.listener({
4319 move |this, _, _, cx| {
4320 this.clear_thread_error(cx);
4321 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
4322 }
4323 }))
4324 }
4325
4326 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4327 self.thread_state = Self::initial_state(
4328 self.agent.clone(),
4329 None,
4330 self.workspace.clone(),
4331 self.project.clone(),
4332 window,
4333 cx,
4334 );
4335 cx.notify();
4336 }
4337
4338 pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
4339 let task = match entry {
4340 HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
4341 history.delete_thread(thread.id.clone(), cx)
4342 }),
4343 HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| {
4344 history.delete_text_thread(context.path.clone(), cx)
4345 }),
4346 };
4347 task.detach_and_log_err(cx);
4348 }
4349}
4350
4351impl Focusable for AcpThreadView {
4352 fn focus_handle(&self, cx: &App) -> FocusHandle {
4353 self.message_editor.focus_handle(cx)
4354 }
4355}
4356
4357impl Render for AcpThreadView {
4358 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4359 let has_messages = self.list_state.item_count() > 0;
4360 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
4361
4362 v_flex()
4363 .size_full()
4364 .key_context("AcpThread")
4365 .on_action(cx.listener(Self::open_agent_diff))
4366 .on_action(cx.listener(Self::toggle_burn_mode))
4367 .on_action(cx.listener(Self::keep_all))
4368 .on_action(cx.listener(Self::reject_all))
4369 .bg(cx.theme().colors().panel_background)
4370 .child(match &self.thread_state {
4371 ThreadState::Unauthenticated {
4372 connection,
4373 description,
4374 configuration_view,
4375 ..
4376 } => self.render_auth_required_state(
4377 connection,
4378 description.as_ref(),
4379 configuration_view.as_ref(),
4380 window,
4381 cx,
4382 ),
4383 ThreadState::Loading { .. } => {
4384 v_flex().flex_1().child(self.render_empty_state(window, cx))
4385 }
4386 ThreadState::LoadError(e) => v_flex()
4387 .p_2()
4388 .flex_1()
4389 .items_center()
4390 .justify_center()
4391 .child(self.render_load_error(e, cx)),
4392 ThreadState::Ready { thread, .. } => {
4393 let thread_clone = thread.clone();
4394
4395 v_flex().flex_1().map(|this| {
4396 if has_messages {
4397 this.child(
4398 list(
4399 self.list_state.clone(),
4400 cx.processor(|this, index: usize, window, cx| {
4401 let Some((entry, len)) = this.thread().and_then(|thread| {
4402 let entries = &thread.read(cx).entries();
4403 Some((entries.get(index)?, entries.len()))
4404 }) else {
4405 return Empty.into_any();
4406 };
4407 this.render_entry(index, len, entry, window, cx)
4408 }),
4409 )
4410 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
4411 .flex_grow()
4412 .into_any(),
4413 )
4414 .child(self.render_vertical_scrollbar(cx))
4415 .children(
4416 match thread_clone.read(cx).status() {
4417 ThreadStatus::Idle
4418 | ThreadStatus::WaitingForToolConfirmation => None,
4419 ThreadStatus::Generating => div()
4420 .px_5()
4421 .py_2()
4422 .child(LoadingLabel::new("").size(LabelSize::Small))
4423 .into(),
4424 },
4425 )
4426 } else {
4427 this.child(self.render_empty_state(window, cx))
4428 }
4429 })
4430 }
4431 })
4432 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
4433 // above so that the scrollbar doesn't render behind it. The current setup allows
4434 // the scrollbar to stop exactly at the activity bar start.
4435 .when(has_messages, |this| match &self.thread_state {
4436 ThreadState::Ready { thread, .. } => {
4437 this.children(self.render_activity_bar(thread, window, cx))
4438 }
4439 _ => this,
4440 })
4441 .children(self.render_thread_retry_status_callout(window, cx))
4442 .children(self.render_thread_error(window, cx))
4443 .children(
4444 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
4445 Some(usage_callout.into_any_element())
4446 } else {
4447 self.render_token_limit_callout(line_height, cx)
4448 .map(|token_limit_callout| token_limit_callout.into_any_element())
4449 },
4450 )
4451 .child(self.render_message_editor(window, cx))
4452 }
4453}
4454
4455fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
4456 let theme_settings = ThemeSettings::get_global(cx);
4457 let colors = cx.theme().colors();
4458
4459 let buffer_font_size = TextSize::Small.rems(cx);
4460
4461 let mut text_style = window.text_style();
4462 let line_height = buffer_font_size * 1.75;
4463
4464 let font_family = if buffer_font {
4465 theme_settings.buffer_font.family.clone()
4466 } else {
4467 theme_settings.ui_font.family.clone()
4468 };
4469
4470 let font_size = if buffer_font {
4471 TextSize::Small.rems(cx)
4472 } else {
4473 TextSize::Default.rems(cx)
4474 };
4475
4476 text_style.refine(&TextStyleRefinement {
4477 font_family: Some(font_family),
4478 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
4479 font_features: Some(theme_settings.ui_font.features.clone()),
4480 font_size: Some(font_size.into()),
4481 line_height: Some(line_height.into()),
4482 color: Some(cx.theme().colors().text),
4483 ..Default::default()
4484 });
4485
4486 MarkdownStyle {
4487 base_text_style: text_style.clone(),
4488 syntax: cx.theme().syntax().clone(),
4489 selection_background_color: cx.theme().colors().element_selection_background,
4490 code_block_overflow_x_scroll: true,
4491 table_overflow_x_scroll: true,
4492 heading_level_styles: Some(HeadingLevelStyles {
4493 h1: Some(TextStyleRefinement {
4494 font_size: Some(rems(1.15).into()),
4495 ..Default::default()
4496 }),
4497 h2: Some(TextStyleRefinement {
4498 font_size: Some(rems(1.1).into()),
4499 ..Default::default()
4500 }),
4501 h3: Some(TextStyleRefinement {
4502 font_size: Some(rems(1.05).into()),
4503 ..Default::default()
4504 }),
4505 h4: Some(TextStyleRefinement {
4506 font_size: Some(rems(1.).into()),
4507 ..Default::default()
4508 }),
4509 h5: Some(TextStyleRefinement {
4510 font_size: Some(rems(0.95).into()),
4511 ..Default::default()
4512 }),
4513 h6: Some(TextStyleRefinement {
4514 font_size: Some(rems(0.875).into()),
4515 ..Default::default()
4516 }),
4517 }),
4518 code_block: StyleRefinement {
4519 padding: EdgesRefinement {
4520 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4521 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4522 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4523 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4524 },
4525 margin: EdgesRefinement {
4526 top: Some(Length::Definite(Pixels(8.).into())),
4527 left: Some(Length::Definite(Pixels(0.).into())),
4528 right: Some(Length::Definite(Pixels(0.).into())),
4529 bottom: Some(Length::Definite(Pixels(12.).into())),
4530 },
4531 border_style: Some(BorderStyle::Solid),
4532 border_widths: EdgesRefinement {
4533 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
4534 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
4535 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
4536 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
4537 },
4538 border_color: Some(colors.border_variant),
4539 background: Some(colors.editor_background.into()),
4540 text: Some(TextStyleRefinement {
4541 font_family: Some(theme_settings.buffer_font.family.clone()),
4542 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
4543 font_features: Some(theme_settings.buffer_font.features.clone()),
4544 font_size: Some(buffer_font_size.into()),
4545 ..Default::default()
4546 }),
4547 ..Default::default()
4548 },
4549 inline_code: TextStyleRefinement {
4550 font_family: Some(theme_settings.buffer_font.family.clone()),
4551 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
4552 font_features: Some(theme_settings.buffer_font.features.clone()),
4553 font_size: Some(buffer_font_size.into()),
4554 background_color: Some(colors.editor_foreground.opacity(0.08)),
4555 ..Default::default()
4556 },
4557 link: TextStyleRefinement {
4558 background_color: Some(colors.editor_foreground.opacity(0.025)),
4559 underline: Some(UnderlineStyle {
4560 color: Some(colors.text_accent.opacity(0.5)),
4561 thickness: px(1.),
4562 ..Default::default()
4563 }),
4564 ..Default::default()
4565 },
4566 ..Default::default()
4567 }
4568}
4569
4570fn plan_label_markdown_style(
4571 status: &acp::PlanEntryStatus,
4572 window: &Window,
4573 cx: &App,
4574) -> MarkdownStyle {
4575 let default_md_style = default_markdown_style(false, window, cx);
4576
4577 MarkdownStyle {
4578 base_text_style: TextStyle {
4579 color: cx.theme().colors().text_muted,
4580 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
4581 Some(gpui::StrikethroughStyle {
4582 thickness: px(1.),
4583 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
4584 })
4585 } else {
4586 None
4587 },
4588 ..default_md_style.base_text_style
4589 },
4590 ..default_md_style
4591 }
4592}
4593
4594fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
4595 let default_md_style = default_markdown_style(true, window, cx);
4596
4597 MarkdownStyle {
4598 base_text_style: TextStyle {
4599 ..default_md_style.base_text_style
4600 },
4601 selection_background_color: cx.theme().colors().element_selection_background,
4602 ..Default::default()
4603 }
4604}
4605
4606#[cfg(test)]
4607pub(crate) mod tests {
4608 use acp_thread::StubAgentConnection;
4609 use agent_client_protocol::SessionId;
4610 use assistant_context::ContextStore;
4611 use editor::EditorSettings;
4612 use fs::FakeFs;
4613 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
4614 use project::Project;
4615 use serde_json::json;
4616 use settings::SettingsStore;
4617 use std::any::Any;
4618 use std::path::Path;
4619 use workspace::Item;
4620
4621 use super::*;
4622
4623 #[gpui::test]
4624 async fn test_drop(cx: &mut TestAppContext) {
4625 init_test(cx);
4626
4627 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
4628 let weak_view = thread_view.downgrade();
4629 drop(thread_view);
4630 assert!(!weak_view.is_upgradable());
4631 }
4632
4633 #[gpui::test]
4634 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
4635 init_test(cx);
4636
4637 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
4638
4639 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4640 message_editor.update_in(cx, |editor, window, cx| {
4641 editor.set_text("Hello", window, cx);
4642 });
4643
4644 cx.deactivate_window();
4645
4646 thread_view.update_in(cx, |thread_view, window, cx| {
4647 thread_view.send(window, cx);
4648 });
4649
4650 cx.run_until_parked();
4651
4652 assert!(
4653 cx.windows()
4654 .iter()
4655 .any(|window| window.downcast::<AgentNotification>().is_some())
4656 );
4657 }
4658
4659 #[gpui::test]
4660 async fn test_notification_for_error(cx: &mut TestAppContext) {
4661 init_test(cx);
4662
4663 let (thread_view, cx) =
4664 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
4665
4666 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4667 message_editor.update_in(cx, |editor, window, cx| {
4668 editor.set_text("Hello", window, cx);
4669 });
4670
4671 cx.deactivate_window();
4672
4673 thread_view.update_in(cx, |thread_view, window, cx| {
4674 thread_view.send(window, cx);
4675 });
4676
4677 cx.run_until_parked();
4678
4679 assert!(
4680 cx.windows()
4681 .iter()
4682 .any(|window| window.downcast::<AgentNotification>().is_some())
4683 );
4684 }
4685
4686 #[gpui::test]
4687 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
4688 init_test(cx);
4689
4690 let tool_call_id = acp::ToolCallId("1".into());
4691 let tool_call = acp::ToolCall {
4692 id: tool_call_id.clone(),
4693 title: "Label".into(),
4694 kind: acp::ToolKind::Edit,
4695 status: acp::ToolCallStatus::Pending,
4696 content: vec!["hi".into()],
4697 locations: vec![],
4698 raw_input: None,
4699 raw_output: None,
4700 };
4701 let connection =
4702 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4703 tool_call_id,
4704 vec![acp::PermissionOption {
4705 id: acp::PermissionOptionId("1".into()),
4706 name: "Allow".into(),
4707 kind: acp::PermissionOptionKind::AllowOnce,
4708 }],
4709 )]));
4710
4711 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4712
4713 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4714
4715 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4716 message_editor.update_in(cx, |editor, window, cx| {
4717 editor.set_text("Hello", window, cx);
4718 });
4719
4720 cx.deactivate_window();
4721
4722 thread_view.update_in(cx, |thread_view, window, cx| {
4723 thread_view.send(window, cx);
4724 });
4725
4726 cx.run_until_parked();
4727
4728 assert!(
4729 cx.windows()
4730 .iter()
4731 .any(|window| window.downcast::<AgentNotification>().is_some())
4732 );
4733 }
4734
4735 async fn setup_thread_view(
4736 agent: impl AgentServer + 'static,
4737 cx: &mut TestAppContext,
4738 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
4739 let fs = FakeFs::new(cx.executor());
4740 let project = Project::test(fs, [], cx).await;
4741 let (workspace, cx) =
4742 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
4743
4744 let context_store =
4745 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
4746 let history_store =
4747 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
4748
4749 let thread_view = cx.update(|window, cx| {
4750 cx.new(|cx| {
4751 AcpThreadView::new(
4752 Rc::new(agent),
4753 None,
4754 None,
4755 workspace.downgrade(),
4756 project,
4757 history_store,
4758 None,
4759 window,
4760 cx,
4761 )
4762 })
4763 });
4764 cx.run_until_parked();
4765 (thread_view, cx)
4766 }
4767
4768 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
4769 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
4770
4771 workspace
4772 .update_in(cx, |workspace, window, cx| {
4773 workspace.add_item_to_active_pane(
4774 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
4775 None,
4776 true,
4777 window,
4778 cx,
4779 );
4780 })
4781 .unwrap();
4782 }
4783
4784 struct ThreadViewItem(Entity<AcpThreadView>);
4785
4786 impl Item for ThreadViewItem {
4787 type Event = ();
4788
4789 fn include_in_nav_history() -> bool {
4790 false
4791 }
4792
4793 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
4794 "Test".into()
4795 }
4796 }
4797
4798 impl EventEmitter<()> for ThreadViewItem {}
4799
4800 impl Focusable for ThreadViewItem {
4801 fn focus_handle(&self, cx: &App) -> FocusHandle {
4802 self.0.read(cx).focus_handle(cx)
4803 }
4804 }
4805
4806 impl Render for ThreadViewItem {
4807 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4808 self.0.clone().into_any_element()
4809 }
4810 }
4811
4812 struct StubAgentServer<C> {
4813 connection: C,
4814 }
4815
4816 impl<C> StubAgentServer<C> {
4817 fn new(connection: C) -> Self {
4818 Self { connection }
4819 }
4820 }
4821
4822 impl StubAgentServer<StubAgentConnection> {
4823 fn default_response() -> Self {
4824 let conn = StubAgentConnection::new();
4825 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4826 content: "Default response".into(),
4827 }]);
4828 Self::new(conn)
4829 }
4830 }
4831
4832 impl<C> AgentServer for StubAgentServer<C>
4833 where
4834 C: 'static + AgentConnection + Send + Clone,
4835 {
4836 fn logo(&self) -> ui::IconName {
4837 ui::IconName::Ai
4838 }
4839
4840 fn name(&self) -> &'static str {
4841 "Test"
4842 }
4843
4844 fn empty_state_headline(&self) -> &'static str {
4845 "Test"
4846 }
4847
4848 fn empty_state_message(&self) -> &'static str {
4849 "Test"
4850 }
4851
4852 fn connect(
4853 &self,
4854 _root_dir: &Path,
4855 _project: &Entity<Project>,
4856 _cx: &mut App,
4857 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4858 Task::ready(Ok(Rc::new(self.connection.clone())))
4859 }
4860
4861 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4862 self
4863 }
4864 }
4865
4866 #[derive(Clone)]
4867 struct SaboteurAgentConnection;
4868
4869 impl AgentConnection for SaboteurAgentConnection {
4870 fn new_thread(
4871 self: Rc<Self>,
4872 project: Entity<Project>,
4873 _cwd: &Path,
4874 cx: &mut gpui::App,
4875 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4876 Task::ready(Ok(cx.new(|cx| {
4877 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4878 AcpThread::new(
4879 "SaboteurAgentConnection",
4880 self,
4881 project,
4882 action_log,
4883 SessionId("test".into()),
4884 )
4885 })))
4886 }
4887
4888 fn auth_methods(&self) -> &[acp::AuthMethod] {
4889 &[]
4890 }
4891
4892 fn prompt_capabilities(&self) -> acp::PromptCapabilities {
4893 acp::PromptCapabilities {
4894 image: true,
4895 audio: true,
4896 embedded_context: true,
4897 }
4898 }
4899
4900 fn authenticate(
4901 &self,
4902 _method_id: acp::AuthMethodId,
4903 _cx: &mut App,
4904 ) -> Task<gpui::Result<()>> {
4905 unimplemented!()
4906 }
4907
4908 fn prompt(
4909 &self,
4910 _id: Option<acp_thread::UserMessageId>,
4911 _params: acp::PromptRequest,
4912 _cx: &mut App,
4913 ) -> Task<gpui::Result<acp::PromptResponse>> {
4914 Task::ready(Err(anyhow::anyhow!("Error prompting")))
4915 }
4916
4917 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4918 unimplemented!()
4919 }
4920
4921 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4922 self
4923 }
4924 }
4925
4926 pub(crate) fn init_test(cx: &mut TestAppContext) {
4927 cx.update(|cx| {
4928 let settings_store = SettingsStore::test(cx);
4929 cx.set_global(settings_store);
4930 language::init(cx);
4931 Project::init_settings(cx);
4932 AgentSettings::register(cx);
4933 workspace::init_settings(cx);
4934 ThemeSettings::register(cx);
4935 release_channel::init(SemanticVersion::default(), cx);
4936 EditorSettings::register(cx);
4937 prompt_store::init(cx)
4938 });
4939 }
4940
4941 #[gpui::test]
4942 async fn test_rewind_views(cx: &mut TestAppContext) {
4943 init_test(cx);
4944
4945 let fs = FakeFs::new(cx.executor());
4946 fs.insert_tree(
4947 "/project",
4948 json!({
4949 "test1.txt": "old content 1",
4950 "test2.txt": "old content 2"
4951 }),
4952 )
4953 .await;
4954 let project = Project::test(fs, [Path::new("/project")], cx).await;
4955 let (workspace, cx) =
4956 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
4957
4958 let context_store =
4959 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
4960 let history_store =
4961 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
4962
4963 let connection = Rc::new(StubAgentConnection::new());
4964 let thread_view = cx.update(|window, cx| {
4965 cx.new(|cx| {
4966 AcpThreadView::new(
4967 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4968 None,
4969 None,
4970 workspace.downgrade(),
4971 project.clone(),
4972 history_store.clone(),
4973 None,
4974 window,
4975 cx,
4976 )
4977 })
4978 });
4979
4980 cx.run_until_parked();
4981
4982 let thread = thread_view
4983 .read_with(cx, |view, _| view.thread().cloned())
4984 .unwrap();
4985
4986 // First user message
4987 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4988 id: acp::ToolCallId("tool1".into()),
4989 title: "Edit file 1".into(),
4990 kind: acp::ToolKind::Edit,
4991 status: acp::ToolCallStatus::Completed,
4992 content: vec![acp::ToolCallContent::Diff {
4993 diff: acp::Diff {
4994 path: "/project/test1.txt".into(),
4995 old_text: Some("old content 1".into()),
4996 new_text: "new content 1".into(),
4997 },
4998 }],
4999 locations: vec![],
5000 raw_input: None,
5001 raw_output: None,
5002 })]);
5003
5004 thread
5005 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
5006 .await
5007 .unwrap();
5008 cx.run_until_parked();
5009
5010 thread.read_with(cx, |thread, _| {
5011 assert_eq!(thread.entries().len(), 2);
5012 });
5013
5014 thread_view.read_with(cx, |view, cx| {
5015 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5016 assert!(
5017 entry_view_state
5018 .entry(0)
5019 .unwrap()
5020 .message_editor()
5021 .is_some()
5022 );
5023 assert!(entry_view_state.entry(1).unwrap().has_content());
5024 });
5025 });
5026
5027 // Second user message
5028 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5029 id: acp::ToolCallId("tool2".into()),
5030 title: "Edit file 2".into(),
5031 kind: acp::ToolKind::Edit,
5032 status: acp::ToolCallStatus::Completed,
5033 content: vec![acp::ToolCallContent::Diff {
5034 diff: acp::Diff {
5035 path: "/project/test2.txt".into(),
5036 old_text: Some("old content 2".into()),
5037 new_text: "new content 2".into(),
5038 },
5039 }],
5040 locations: vec![],
5041 raw_input: None,
5042 raw_output: None,
5043 })]);
5044
5045 thread
5046 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
5047 .await
5048 .unwrap();
5049 cx.run_until_parked();
5050
5051 let second_user_message_id = thread.read_with(cx, |thread, _| {
5052 assert_eq!(thread.entries().len(), 4);
5053 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
5054 panic!();
5055 };
5056 user_message.id.clone().unwrap()
5057 });
5058
5059 thread_view.read_with(cx, |view, cx| {
5060 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5061 assert!(
5062 entry_view_state
5063 .entry(0)
5064 .unwrap()
5065 .message_editor()
5066 .is_some()
5067 );
5068 assert!(entry_view_state.entry(1).unwrap().has_content());
5069 assert!(
5070 entry_view_state
5071 .entry(2)
5072 .unwrap()
5073 .message_editor()
5074 .is_some()
5075 );
5076 assert!(entry_view_state.entry(3).unwrap().has_content());
5077 });
5078 });
5079
5080 // Rewind to first message
5081 thread
5082 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
5083 .await
5084 .unwrap();
5085
5086 cx.run_until_parked();
5087
5088 thread.read_with(cx, |thread, _| {
5089 assert_eq!(thread.entries().len(), 2);
5090 });
5091
5092 thread_view.read_with(cx, |view, cx| {
5093 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5094 assert!(
5095 entry_view_state
5096 .entry(0)
5097 .unwrap()
5098 .message_editor()
5099 .is_some()
5100 );
5101 assert!(entry_view_state.entry(1).unwrap().has_content());
5102
5103 // Old views should be dropped
5104 assert!(entry_view_state.entry(2).is_none());
5105 assert!(entry_view_state.entry(3).is_none());
5106 });
5107 });
5108 }
5109
5110 #[gpui::test]
5111 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5112 init_test(cx);
5113
5114 let connection = StubAgentConnection::new();
5115
5116 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5117 content: acp::ContentBlock::Text(acp::TextContent {
5118 text: "Response".into(),
5119 annotations: None,
5120 }),
5121 }]);
5122
5123 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5124 add_to_workspace(thread_view.clone(), cx);
5125
5126 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5127 message_editor.update_in(cx, |editor, window, cx| {
5128 editor.set_text("Original message to edit", window, cx);
5129 });
5130 thread_view.update_in(cx, |thread_view, window, cx| {
5131 thread_view.send(window, cx);
5132 });
5133
5134 cx.run_until_parked();
5135
5136 let user_message_editor = thread_view.read_with(cx, |view, cx| {
5137 assert_eq!(view.editing_message, None);
5138
5139 view.entry_view_state
5140 .read(cx)
5141 .entry(0)
5142 .unwrap()
5143 .message_editor()
5144 .unwrap()
5145 .clone()
5146 });
5147
5148 // Focus
5149 cx.focus(&user_message_editor);
5150 thread_view.read_with(cx, |view, _cx| {
5151 assert_eq!(view.editing_message, Some(0));
5152 });
5153
5154 // Edit
5155 user_message_editor.update_in(cx, |editor, window, cx| {
5156 editor.set_text("Edited message content", window, cx);
5157 });
5158
5159 // Cancel
5160 user_message_editor.update_in(cx, |_editor, window, cx| {
5161 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
5162 });
5163
5164 thread_view.read_with(cx, |view, _cx| {
5165 assert_eq!(view.editing_message, None);
5166 });
5167
5168 user_message_editor.read_with(cx, |editor, cx| {
5169 assert_eq!(editor.text(cx), "Original message to edit");
5170 });
5171 }
5172
5173 #[gpui::test]
5174 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
5175 init_test(cx);
5176
5177 let connection = StubAgentConnection::new();
5178
5179 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5180 add_to_workspace(thread_view.clone(), cx);
5181
5182 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5183 let mut events = cx.events(&message_editor);
5184 message_editor.update_in(cx, |editor, window, cx| {
5185 editor.set_text("", window, cx);
5186 });
5187
5188 message_editor.update_in(cx, |_editor, window, cx| {
5189 window.dispatch_action(Box::new(Chat), cx);
5190 });
5191 cx.run_until_parked();
5192 // We shouldn't have received any messages
5193 assert!(matches!(
5194 events.try_next(),
5195 Err(futures::channel::mpsc::TryRecvError { .. })
5196 ));
5197 }
5198
5199 #[gpui::test]
5200 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
5201 init_test(cx);
5202
5203 let connection = StubAgentConnection::new();
5204
5205 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5206 content: acp::ContentBlock::Text(acp::TextContent {
5207 text: "Response".into(),
5208 annotations: None,
5209 }),
5210 }]);
5211
5212 let (thread_view, cx) =
5213 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5214 add_to_workspace(thread_view.clone(), cx);
5215
5216 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5217 message_editor.update_in(cx, |editor, window, cx| {
5218 editor.set_text("Original message to edit", window, cx);
5219 });
5220 thread_view.update_in(cx, |thread_view, window, cx| {
5221 thread_view.send(window, cx);
5222 });
5223
5224 cx.run_until_parked();
5225
5226 let user_message_editor = thread_view.read_with(cx, |view, cx| {
5227 assert_eq!(view.editing_message, None);
5228 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
5229
5230 view.entry_view_state
5231 .read(cx)
5232 .entry(0)
5233 .unwrap()
5234 .message_editor()
5235 .unwrap()
5236 .clone()
5237 });
5238
5239 // Focus
5240 cx.focus(&user_message_editor);
5241
5242 // Edit
5243 user_message_editor.update_in(cx, |editor, window, cx| {
5244 editor.set_text("Edited message content", window, cx);
5245 });
5246
5247 // Send
5248 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5249 content: acp::ContentBlock::Text(acp::TextContent {
5250 text: "New Response".into(),
5251 annotations: None,
5252 }),
5253 }]);
5254
5255 user_message_editor.update_in(cx, |_editor, window, cx| {
5256 window.dispatch_action(Box::new(Chat), cx);
5257 });
5258
5259 cx.run_until_parked();
5260
5261 thread_view.read_with(cx, |view, cx| {
5262 assert_eq!(view.editing_message, None);
5263
5264 let entries = view.thread().unwrap().read(cx).entries();
5265 assert_eq!(entries.len(), 2);
5266 assert_eq!(
5267 entries[0].to_markdown(cx),
5268 "## User\n\nEdited message content\n\n"
5269 );
5270 assert_eq!(
5271 entries[1].to_markdown(cx),
5272 "## Assistant\n\nNew Response\n\n"
5273 );
5274
5275 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
5276 assert!(!state.entry(1).unwrap().has_content());
5277 state.entry(0).unwrap().message_editor().unwrap().clone()
5278 });
5279
5280 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
5281 })
5282 }
5283
5284 #[gpui::test]
5285 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
5286 init_test(cx);
5287
5288 let connection = StubAgentConnection::new();
5289
5290 let (thread_view, cx) =
5291 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5292 add_to_workspace(thread_view.clone(), cx);
5293
5294 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5295 message_editor.update_in(cx, |editor, window, cx| {
5296 editor.set_text("Original message to edit", window, cx);
5297 });
5298 thread_view.update_in(cx, |thread_view, window, cx| {
5299 thread_view.send(window, cx);
5300 });
5301
5302 cx.run_until_parked();
5303
5304 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
5305 let thread = view.thread().unwrap().read(cx);
5306 assert_eq!(thread.entries().len(), 1);
5307
5308 let editor = view
5309 .entry_view_state
5310 .read(cx)
5311 .entry(0)
5312 .unwrap()
5313 .message_editor()
5314 .unwrap()
5315 .clone();
5316
5317 (editor, thread.session_id().clone())
5318 });
5319
5320 // Focus
5321 cx.focus(&user_message_editor);
5322
5323 thread_view.read_with(cx, |view, _cx| {
5324 assert_eq!(view.editing_message, Some(0));
5325 });
5326
5327 // Edit
5328 user_message_editor.update_in(cx, |editor, window, cx| {
5329 editor.set_text("Edited message content", window, cx);
5330 });
5331
5332 thread_view.read_with(cx, |view, _cx| {
5333 assert_eq!(view.editing_message, Some(0));
5334 });
5335
5336 // Finish streaming response
5337 cx.update(|_, cx| {
5338 connection.send_update(
5339 session_id.clone(),
5340 acp::SessionUpdate::AgentMessageChunk {
5341 content: acp::ContentBlock::Text(acp::TextContent {
5342 text: "Response".into(),
5343 annotations: None,
5344 }),
5345 },
5346 cx,
5347 );
5348 connection.end_turn(session_id, acp::StopReason::EndTurn);
5349 });
5350
5351 thread_view.read_with(cx, |view, _cx| {
5352 assert_eq!(view.editing_message, Some(0));
5353 });
5354
5355 cx.run_until_parked();
5356
5357 // Should still be editing
5358 cx.update(|window, cx| {
5359 assert!(user_message_editor.focus_handle(cx).is_focused(window));
5360 assert_eq!(thread_view.read(cx).editing_message, Some(0));
5361 assert_eq!(
5362 user_message_editor.read(cx).text(cx),
5363 "Edited message content"
5364 );
5365 });
5366 }
5367
5368 #[gpui::test]
5369 async fn test_interrupt(cx: &mut TestAppContext) {
5370 init_test(cx);
5371
5372 let connection = StubAgentConnection::new();
5373
5374 let (thread_view, cx) =
5375 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5376 add_to_workspace(thread_view.clone(), cx);
5377
5378 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5379 message_editor.update_in(cx, |editor, window, cx| {
5380 editor.set_text("Message 1", window, cx);
5381 });
5382 thread_view.update_in(cx, |thread_view, window, cx| {
5383 thread_view.send(window, cx);
5384 });
5385
5386 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
5387 let thread = view.thread().unwrap();
5388
5389 (thread.clone(), thread.read(cx).session_id().clone())
5390 });
5391
5392 cx.run_until_parked();
5393
5394 cx.update(|_, cx| {
5395 connection.send_update(
5396 session_id.clone(),
5397 acp::SessionUpdate::AgentMessageChunk {
5398 content: "Message 1 resp".into(),
5399 },
5400 cx,
5401 );
5402 });
5403
5404 cx.run_until_parked();
5405
5406 thread.read_with(cx, |thread, cx| {
5407 assert_eq!(
5408 thread.to_markdown(cx),
5409 indoc::indoc! {"
5410 ## User
5411
5412 Message 1
5413
5414 ## Assistant
5415
5416 Message 1 resp
5417
5418 "}
5419 )
5420 });
5421
5422 message_editor.update_in(cx, |editor, window, cx| {
5423 editor.set_text("Message 2", window, cx);
5424 });
5425 thread_view.update_in(cx, |thread_view, window, cx| {
5426 thread_view.send(window, cx);
5427 });
5428
5429 cx.update(|_, cx| {
5430 // Simulate a response sent after beginning to cancel
5431 connection.send_update(
5432 session_id.clone(),
5433 acp::SessionUpdate::AgentMessageChunk {
5434 content: "onse".into(),
5435 },
5436 cx,
5437 );
5438 });
5439
5440 cx.run_until_parked();
5441
5442 // Last Message 1 response should appear before Message 2
5443 thread.read_with(cx, |thread, cx| {
5444 assert_eq!(
5445 thread.to_markdown(cx),
5446 indoc::indoc! {"
5447 ## User
5448
5449 Message 1
5450
5451 ## Assistant
5452
5453 Message 1 response
5454
5455 ## User
5456
5457 Message 2
5458
5459 "}
5460 )
5461 });
5462
5463 cx.update(|_, cx| {
5464 connection.send_update(
5465 session_id.clone(),
5466 acp::SessionUpdate::AgentMessageChunk {
5467 content: "Message 2 response".into(),
5468 },
5469 cx,
5470 );
5471 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5472 });
5473
5474 cx.run_until_parked();
5475
5476 thread.read_with(cx, |thread, cx| {
5477 assert_eq!(
5478 thread.to_markdown(cx),
5479 indoc::indoc! {"
5480 ## User
5481
5482 Message 1
5483
5484 ## Assistant
5485
5486 Message 1 response
5487
5488 ## User
5489
5490 Message 2
5491
5492 ## Assistant
5493
5494 Message 2 response
5495
5496 "}
5497 )
5498 });
5499 }
5500}