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