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