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