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_12()
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 .child(h_flex().pr_8().child(self.render_markdown(
1841 tool_call.label.clone(),
1842 default_markdown_style(false, true, window, cx),
1843 )))
1844 .child(gradient_overlay(gradient_color))
1845 .on_click(cx.listener({
1846 let id = tool_call.id.clone();
1847 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1848 if is_open {
1849 this.expanded_tool_calls.remove(&id);
1850 } else {
1851 this.expanded_tool_calls.insert(id.clone());
1852 }
1853 cx.notify();
1854 }
1855 }))
1856 .into_any()
1857 }),
1858 )
1859 .children(status_icon),
1860 )
1861 .children(tool_output_display)
1862 }
1863
1864 fn render_tool_call_content(
1865 &self,
1866 entry_ix: usize,
1867 content: &ToolCallContent,
1868 tool_call: &ToolCall,
1869 window: &Window,
1870 cx: &Context<Self>,
1871 ) -> AnyElement {
1872 match content {
1873 ToolCallContent::ContentBlock(content) => {
1874 if let Some(resource_link) = content.resource_link() {
1875 self.render_resource_link(resource_link, cx)
1876 } else if let Some(markdown) = content.markdown() {
1877 self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx)
1878 } else {
1879 Empty.into_any_element()
1880 }
1881 }
1882 ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, tool_call, cx),
1883 ToolCallContent::Terminal(terminal) => {
1884 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
1885 }
1886 }
1887 }
1888
1889 fn render_markdown_output(
1890 &self,
1891 markdown: Entity<Markdown>,
1892 tool_call_id: acp::ToolCallId,
1893 window: &Window,
1894 cx: &Context<Self>,
1895 ) -> AnyElement {
1896 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
1897
1898 v_flex()
1899 .mt_1p5()
1900 .ml(px(7.))
1901 .px_3p5()
1902 .gap_2()
1903 .border_l_1()
1904 .border_color(self.tool_card_border_color(cx))
1905 .text_sm()
1906 .text_color(cx.theme().colors().text_muted)
1907 .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
1908 .child(
1909 Button::new(button_id, "Collapse")
1910 .full_width()
1911 .style(ButtonStyle::Outlined)
1912 .label_size(LabelSize::Small)
1913 .icon(IconName::ChevronUp)
1914 .icon_color(Color::Muted)
1915 .icon_position(IconPosition::Start)
1916 .on_click(cx.listener({
1917 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1918 this.expanded_tool_calls.remove(&tool_call_id);
1919 cx.notify();
1920 }
1921 })),
1922 )
1923 .into_any_element()
1924 }
1925
1926 fn render_resource_link(
1927 &self,
1928 resource_link: &acp::ResourceLink,
1929 cx: &Context<Self>,
1930 ) -> AnyElement {
1931 let uri: SharedString = resource_link.uri.clone().into();
1932
1933 let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") {
1934 path.to_string().into()
1935 } else {
1936 uri.clone()
1937 };
1938
1939 let button_id = SharedString::from(format!("item-{}", uri));
1940
1941 div()
1942 .ml(px(7.))
1943 .pl_2p5()
1944 .border_l_1()
1945 .border_color(self.tool_card_border_color(cx))
1946 .overflow_hidden()
1947 .child(
1948 Button::new(button_id, label)
1949 .label_size(LabelSize::Small)
1950 .color(Color::Muted)
1951 .icon(IconName::ArrowUpRight)
1952 .icon_size(IconSize::XSmall)
1953 .icon_color(Color::Muted)
1954 .truncate(true)
1955 .on_click(cx.listener({
1956 let workspace = self.workspace.clone();
1957 move |_, _, window, cx: &mut Context<Self>| {
1958 Self::open_link(uri.clone(), &workspace, window, cx);
1959 }
1960 })),
1961 )
1962 .into_any_element()
1963 }
1964
1965 fn render_permission_buttons(
1966 &self,
1967 options: &[acp::PermissionOption],
1968 entry_ix: usize,
1969 tool_call_id: acp::ToolCallId,
1970 empty_content: bool,
1971 cx: &Context<Self>,
1972 ) -> Div {
1973 h_flex()
1974 .py_1()
1975 .pl_2()
1976 .pr_1()
1977 .gap_1()
1978 .justify_between()
1979 .flex_wrap()
1980 .when(!empty_content, |this| {
1981 this.border_t_1()
1982 .border_color(self.tool_card_border_color(cx))
1983 })
1984 .child(
1985 div()
1986 .min_w(rems_from_px(145.))
1987 .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
1988 )
1989 .child(h_flex().gap_0p5().children(options.iter().map(|option| {
1990 let option_id = SharedString::from(option.id.0.clone());
1991 Button::new((option_id, entry_ix), option.name.clone())
1992 .map(|this| match option.kind {
1993 acp::PermissionOptionKind::AllowOnce => {
1994 this.icon(IconName::Check).icon_color(Color::Success)
1995 }
1996 acp::PermissionOptionKind::AllowAlways => {
1997 this.icon(IconName::CheckDouble).icon_color(Color::Success)
1998 }
1999 acp::PermissionOptionKind::RejectOnce => {
2000 this.icon(IconName::Close).icon_color(Color::Error)
2001 }
2002 acp::PermissionOptionKind::RejectAlways => {
2003 this.icon(IconName::Close).icon_color(Color::Error)
2004 }
2005 })
2006 .icon_position(IconPosition::Start)
2007 .icon_size(IconSize::XSmall)
2008 .label_size(LabelSize::Small)
2009 .on_click(cx.listener({
2010 let tool_call_id = tool_call_id.clone();
2011 let option_id = option.id.clone();
2012 let option_kind = option.kind;
2013 move |this, _, _, cx| {
2014 this.authorize_tool_call(
2015 tool_call_id.clone(),
2016 option_id.clone(),
2017 option_kind,
2018 cx,
2019 );
2020 }
2021 }))
2022 })))
2023 }
2024
2025 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
2026 let bar = |n: u64, width_class: &str| {
2027 let bg_color = cx.theme().colors().element_active;
2028 let base = h_flex().h_1().rounded_full();
2029
2030 let modified = match width_class {
2031 "w_4_5" => base.w_3_4(),
2032 "w_1_4" => base.w_1_4(),
2033 "w_2_4" => base.w_2_4(),
2034 "w_3_5" => base.w_3_5(),
2035 "w_2_5" => base.w_2_5(),
2036 _ => base.w_1_2(),
2037 };
2038
2039 modified.with_animation(
2040 ElementId::Integer(n),
2041 Animation::new(Duration::from_secs(2)).repeat(),
2042 move |tab, delta| {
2043 let delta = (delta - 0.15 * n as f32) / 0.7;
2044 let delta = 1.0 - (0.5 - delta).abs() * 2.;
2045 let delta = ease_in_out(delta.clamp(0., 1.));
2046 let delta = 0.1 + 0.9 * delta;
2047
2048 tab.bg(bg_color.opacity(delta))
2049 },
2050 )
2051 };
2052
2053 v_flex()
2054 .p_3()
2055 .gap_1()
2056 .rounded_b_md()
2057 .bg(cx.theme().colors().editor_background)
2058 .child(bar(0, "w_4_5"))
2059 .child(bar(1, "w_1_4"))
2060 .child(bar(2, "w_2_4"))
2061 .child(bar(3, "w_3_5"))
2062 .child(bar(4, "w_2_5"))
2063 .into_any_element()
2064 }
2065
2066 fn render_diff_editor(
2067 &self,
2068 entry_ix: usize,
2069 diff: &Entity<acp_thread::Diff>,
2070 tool_call: &ToolCall,
2071 cx: &Context<Self>,
2072 ) -> AnyElement {
2073 let tool_progress = matches!(
2074 &tool_call.status,
2075 ToolCallStatus::InProgress | ToolCallStatus::Pending
2076 );
2077
2078 v_flex()
2079 .h_full()
2080 .child(
2081 if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
2082 && let Some(editor) = entry.editor_for_diff(diff)
2083 && diff.read(cx).has_revealed_range(cx)
2084 {
2085 editor.into_any_element()
2086 } else if tool_progress && self.as_native_connection(cx).is_some() {
2087 self.render_diff_loading(cx)
2088 } else {
2089 Empty.into_any()
2090 },
2091 )
2092 .into_any()
2093 }
2094
2095 fn render_terminal_tool_call(
2096 &self,
2097 entry_ix: usize,
2098 terminal: &Entity<acp_thread::Terminal>,
2099 tool_call: &ToolCall,
2100 window: &Window,
2101 cx: &Context<Self>,
2102 ) -> AnyElement {
2103 let terminal_data = terminal.read(cx);
2104 let working_dir = terminal_data.working_dir();
2105 let command = terminal_data.command();
2106 let started_at = terminal_data.started_at();
2107
2108 let tool_failed = matches!(
2109 &tool_call.status,
2110 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
2111 );
2112
2113 let output = terminal_data.output();
2114 let command_finished = output.is_some();
2115 let truncated_output = output.is_some_and(|output| output.was_content_truncated);
2116 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
2117
2118 let command_failed = command_finished
2119 && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success()));
2120
2121 let time_elapsed = if let Some(output) = output {
2122 output.ended_at.duration_since(started_at)
2123 } else {
2124 started_at.elapsed()
2125 };
2126
2127 let header_bg = cx
2128 .theme()
2129 .colors()
2130 .element_background
2131 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
2132 let border_color = cx.theme().colors().border.opacity(0.6);
2133
2134 let working_dir = working_dir
2135 .as_ref()
2136 .map(|path| format!("{}", path.display()))
2137 .unwrap_or_else(|| "current directory".to_string());
2138
2139 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
2140
2141 let header = h_flex()
2142 .id(SharedString::from(format!(
2143 "terminal-tool-header-{}",
2144 terminal.entity_id()
2145 )))
2146 .flex_none()
2147 .gap_1()
2148 .justify_between()
2149 .rounded_t_md()
2150 .child(
2151 div()
2152 .id(("command-target-path", terminal.entity_id()))
2153 .w_full()
2154 .max_w_full()
2155 .overflow_x_scroll()
2156 .child(
2157 Label::new(working_dir)
2158 .buffer_font(cx)
2159 .size(LabelSize::XSmall)
2160 .color(Color::Muted),
2161 ),
2162 )
2163 .when(!command_finished, |header| {
2164 header
2165 .gap_1p5()
2166 .child(
2167 Button::new(
2168 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
2169 "Stop",
2170 )
2171 .icon(IconName::Stop)
2172 .icon_position(IconPosition::Start)
2173 .icon_size(IconSize::Small)
2174 .icon_color(Color::Error)
2175 .label_size(LabelSize::Small)
2176 .tooltip(move |window, cx| {
2177 Tooltip::with_meta(
2178 "Stop This Command",
2179 None,
2180 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
2181 window,
2182 cx,
2183 )
2184 })
2185 .on_click({
2186 let terminal = terminal.clone();
2187 cx.listener(move |_this, _event, _window, cx| {
2188 let inner_terminal = terminal.read(cx).inner().clone();
2189 inner_terminal.update(cx, |inner_terminal, _cx| {
2190 inner_terminal.kill_active_task();
2191 });
2192 })
2193 }),
2194 )
2195 .child(Divider::vertical())
2196 .child(
2197 Icon::new(IconName::ArrowCircle)
2198 .size(IconSize::XSmall)
2199 .color(Color::Info)
2200 .with_animation(
2201 "arrow-circle",
2202 Animation::new(Duration::from_secs(2)).repeat(),
2203 |icon, delta| {
2204 icon.transform(Transformation::rotate(percentage(delta)))
2205 },
2206 ),
2207 )
2208 })
2209 .when(tool_failed || command_failed, |header| {
2210 header.child(
2211 div()
2212 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
2213 .child(
2214 Icon::new(IconName::Close)
2215 .size(IconSize::Small)
2216 .color(Color::Error),
2217 )
2218 .when_some(output.and_then(|o| o.exit_status), |this, status| {
2219 this.tooltip(Tooltip::text(format!(
2220 "Exited with code {}",
2221 status.code().unwrap_or(-1),
2222 )))
2223 }),
2224 )
2225 })
2226 .when(truncated_output, |header| {
2227 let tooltip = if let Some(output) = output {
2228 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
2229 "Output exceeded terminal max lines and was \
2230 truncated, the model received the first 16 KB."
2231 .to_string()
2232 } else {
2233 format!(
2234 "Output is {} long, and to avoid unexpected token usage, \
2235 only 16 KB was sent back to the model.",
2236 format_file_size(output.original_content_len as u64, true),
2237 )
2238 }
2239 } else {
2240 "Output was truncated".to_string()
2241 };
2242
2243 header.child(
2244 h_flex()
2245 .id(("terminal-tool-truncated-label", terminal.entity_id()))
2246 .gap_1()
2247 .child(
2248 Icon::new(IconName::Info)
2249 .size(IconSize::XSmall)
2250 .color(Color::Ignored),
2251 )
2252 .child(
2253 Label::new("Truncated")
2254 .color(Color::Muted)
2255 .size(LabelSize::XSmall),
2256 )
2257 .tooltip(Tooltip::text(tooltip)),
2258 )
2259 })
2260 .when(time_elapsed > Duration::from_secs(10), |header| {
2261 header.child(
2262 Label::new(format!("({})", duration_alt_display(time_elapsed)))
2263 .buffer_font(cx)
2264 .color(Color::Muted)
2265 .size(LabelSize::XSmall),
2266 )
2267 })
2268 .child(
2269 Disclosure::new(
2270 SharedString::from(format!(
2271 "terminal-tool-disclosure-{}",
2272 terminal.entity_id()
2273 )),
2274 is_expanded,
2275 )
2276 .opened_icon(IconName::ChevronUp)
2277 .closed_icon(IconName::ChevronDown)
2278 .on_click(cx.listener({
2279 let id = tool_call.id.clone();
2280 move |this, _event, _window, _cx| {
2281 if is_expanded {
2282 this.expanded_tool_calls.remove(&id);
2283 } else {
2284 this.expanded_tool_calls.insert(id.clone());
2285 }
2286 }
2287 })),
2288 );
2289
2290 let terminal_view = self
2291 .entry_view_state
2292 .read(cx)
2293 .entry(entry_ix)
2294 .and_then(|entry| entry.terminal(terminal));
2295 let show_output = is_expanded && terminal_view.is_some();
2296
2297 v_flex()
2298 .mb_2()
2299 .border_1()
2300 .when(tool_failed || command_failed, |card| card.border_dashed())
2301 .border_color(border_color)
2302 .rounded_md()
2303 .overflow_hidden()
2304 .child(
2305 v_flex()
2306 .py_1p5()
2307 .pl_2()
2308 .pr_1p5()
2309 .gap_0p5()
2310 .bg(header_bg)
2311 .text_xs()
2312 .child(header)
2313 .child(
2314 MarkdownElement::new(
2315 command.clone(),
2316 terminal_command_markdown_style(window, cx),
2317 )
2318 .code_block_renderer(
2319 markdown::CodeBlockRenderer::Default {
2320 copy_button: false,
2321 copy_button_on_hover: true,
2322 border: false,
2323 },
2324 ),
2325 ),
2326 )
2327 .when(show_output, |this| {
2328 this.child(
2329 div()
2330 .pt_2()
2331 .border_t_1()
2332 .when(tool_failed || command_failed, |card| card.border_dashed())
2333 .border_color(border_color)
2334 .bg(cx.theme().colors().editor_background)
2335 .rounded_b_md()
2336 .text_ui_sm(cx)
2337 .children(terminal_view.clone()),
2338 )
2339 })
2340 .into_any()
2341 }
2342
2343 fn render_agent_logo(&self) -> AnyElement {
2344 Icon::new(self.agent.logo())
2345 .color(Color::Muted)
2346 .size(IconSize::XLarge)
2347 .into_any_element()
2348 }
2349
2350 fn render_error_agent_logo(&self) -> AnyElement {
2351 let logo = Icon::new(self.agent.logo())
2352 .color(Color::Muted)
2353 .size(IconSize::XLarge)
2354 .into_any_element();
2355
2356 h_flex()
2357 .relative()
2358 .justify_center()
2359 .child(div().opacity(0.3).child(logo))
2360 .child(
2361 h_flex()
2362 .absolute()
2363 .right_1()
2364 .bottom_0()
2365 .child(Icon::new(IconName::XCircleFilled).color(Color::Error)),
2366 )
2367 .into_any_element()
2368 }
2369
2370 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
2371 let project_context = self
2372 .as_native_thread(cx)?
2373 .read(cx)
2374 .project_context()
2375 .read(cx);
2376
2377 let user_rules_text = if project_context.user_rules.is_empty() {
2378 None
2379 } else if project_context.user_rules.len() == 1 {
2380 let user_rules = &project_context.user_rules[0];
2381
2382 match user_rules.title.as_ref() {
2383 Some(title) => Some(format!("Using \"{title}\" user rule")),
2384 None => Some("Using user rule".into()),
2385 }
2386 } else {
2387 Some(format!(
2388 "Using {} user rules",
2389 project_context.user_rules.len()
2390 ))
2391 };
2392
2393 let first_user_rules_id = project_context
2394 .user_rules
2395 .first()
2396 .map(|user_rules| user_rules.uuid.0);
2397
2398 let rules_files = project_context
2399 .worktrees
2400 .iter()
2401 .filter_map(|worktree| worktree.rules_file.as_ref())
2402 .collect::<Vec<_>>();
2403
2404 let rules_file_text = match rules_files.as_slice() {
2405 &[] => None,
2406 &[rules_file] => Some(format!(
2407 "Using project {:?} file",
2408 rules_file.path_in_worktree
2409 )),
2410 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
2411 };
2412
2413 if user_rules_text.is_none() && rules_file_text.is_none() {
2414 return None;
2415 }
2416
2417 Some(
2418 v_flex()
2419 .px_2p5()
2420 .gap_1()
2421 .when_some(user_rules_text, |parent, user_rules_text| {
2422 parent.child(
2423 h_flex()
2424 .group("user-rules")
2425 .id("user-rules")
2426 .w_full()
2427 .child(
2428 Icon::new(IconName::Reader)
2429 .size(IconSize::XSmall)
2430 .color(Color::Disabled),
2431 )
2432 .child(
2433 Label::new(user_rules_text)
2434 .size(LabelSize::XSmall)
2435 .color(Color::Muted)
2436 .truncate()
2437 .buffer_font(cx)
2438 .ml_1p5()
2439 .mr_0p5(),
2440 )
2441 .child(
2442 IconButton::new("open-prompt-library", IconName::ArrowUpRight)
2443 .shape(ui::IconButtonShape::Square)
2444 .icon_size(IconSize::XSmall)
2445 .icon_color(Color::Ignored)
2446 .visible_on_hover("user-rules")
2447 // TODO: Figure out a way to pass focus handle here so we can display the `OpenRulesLibrary` keybinding
2448 .tooltip(Tooltip::text("View User Rules")),
2449 )
2450 .on_click(move |_event, window, cx| {
2451 window.dispatch_action(
2452 Box::new(OpenRulesLibrary {
2453 prompt_to_select: first_user_rules_id,
2454 }),
2455 cx,
2456 )
2457 }),
2458 )
2459 })
2460 .when_some(rules_file_text, |parent, rules_file_text| {
2461 parent.child(
2462 h_flex()
2463 .group("project-rules")
2464 .id("project-rules")
2465 .w_full()
2466 .child(
2467 Icon::new(IconName::Reader)
2468 .size(IconSize::XSmall)
2469 .color(Color::Disabled),
2470 )
2471 .child(
2472 Label::new(rules_file_text)
2473 .size(LabelSize::XSmall)
2474 .color(Color::Muted)
2475 .buffer_font(cx)
2476 .ml_1p5()
2477 .mr_0p5(),
2478 )
2479 .child(
2480 IconButton::new("open-rule", IconName::ArrowUpRight)
2481 .shape(ui::IconButtonShape::Square)
2482 .icon_size(IconSize::XSmall)
2483 .icon_color(Color::Ignored)
2484 .visible_on_hover("project-rules")
2485 .tooltip(Tooltip::text("View Project Rules")),
2486 )
2487 .on_click(cx.listener(Self::handle_open_rules)),
2488 )
2489 })
2490 .into_any(),
2491 )
2492 }
2493
2494 fn render_empty_state_section_header(
2495 &self,
2496 label: impl Into<SharedString>,
2497 action_slot: Option<AnyElement>,
2498 cx: &mut Context<Self>,
2499 ) -> impl IntoElement {
2500 div().pl_1().pr_1p5().child(
2501 h_flex()
2502 .mt_2()
2503 .pl_1p5()
2504 .pb_1()
2505 .w_full()
2506 .justify_between()
2507 .border_b_1()
2508 .border_color(cx.theme().colors().border_variant)
2509 .child(
2510 Label::new(label.into())
2511 .size(LabelSize::Small)
2512 .color(Color::Muted),
2513 )
2514 .children(action_slot),
2515 )
2516 }
2517
2518 fn render_empty_state(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2519 let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
2520 let render_history = self
2521 .agent
2522 .clone()
2523 .downcast::<agent2::NativeAgentServer>()
2524 .is_some()
2525 && self
2526 .history_store
2527 .update(cx, |history_store, cx| !history_store.is_empty(cx));
2528
2529 v_flex()
2530 .size_full()
2531 .when(!render_history, |this| {
2532 this.child(
2533 v_flex()
2534 .size_full()
2535 .items_center()
2536 .justify_center()
2537 .child(if loading {
2538 h_flex()
2539 .justify_center()
2540 .child(self.render_agent_logo())
2541 .with_animation(
2542 "pulsating_icon",
2543 Animation::new(Duration::from_secs(2))
2544 .repeat()
2545 .with_easing(pulsating_between(0.4, 1.0)),
2546 |icon, delta| icon.opacity(delta),
2547 )
2548 .into_any()
2549 } else {
2550 self.render_agent_logo().into_any_element()
2551 })
2552 .child(h_flex().mt_4().mb_2().justify_center().child(if loading {
2553 div()
2554 .child(LoadingLabel::new("").size(LabelSize::Large))
2555 .into_any_element()
2556 } else {
2557 Headline::new(self.agent.empty_state_headline())
2558 .size(HeadlineSize::Medium)
2559 .into_any_element()
2560 })),
2561 )
2562 })
2563 .when(render_history, |this| {
2564 let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| {
2565 history_store.entries().take(3).collect()
2566 });
2567 this.justify_end().child(
2568 v_flex()
2569 .child(
2570 self.render_empty_state_section_header(
2571 "Recent",
2572 Some(
2573 Button::new("view-history", "View All")
2574 .style(ButtonStyle::Subtle)
2575 .label_size(LabelSize::Small)
2576 .key_binding(
2577 KeyBinding::for_action_in(
2578 &OpenHistory,
2579 &self.focus_handle(cx),
2580 window,
2581 cx,
2582 )
2583 .map(|kb| kb.size(rems_from_px(12.))),
2584 )
2585 .on_click(move |_event, window, cx| {
2586 window.dispatch_action(OpenHistory.boxed_clone(), cx);
2587 })
2588 .into_any_element(),
2589 ),
2590 cx,
2591 ),
2592 )
2593 .child(
2594 v_flex().p_1().pr_1p5().gap_1().children(
2595 recent_history
2596 .into_iter()
2597 .enumerate()
2598 .map(|(index, entry)| {
2599 // TODO: Add keyboard navigation.
2600 let is_hovered =
2601 self.hovered_recent_history_item == Some(index);
2602 crate::acp::thread_history::AcpHistoryEntryElement::new(
2603 entry,
2604 cx.entity().downgrade(),
2605 )
2606 .hovered(is_hovered)
2607 .on_hover(cx.listener(
2608 move |this, is_hovered, _window, cx| {
2609 if *is_hovered {
2610 this.hovered_recent_history_item = Some(index);
2611 } else if this.hovered_recent_history_item
2612 == Some(index)
2613 {
2614 this.hovered_recent_history_item = None;
2615 }
2616 cx.notify();
2617 },
2618 ))
2619 .into_any_element()
2620 }),
2621 ),
2622 ),
2623 )
2624 })
2625 .into_any()
2626 }
2627
2628 fn render_auth_required_state(
2629 &self,
2630 connection: &Rc<dyn AgentConnection>,
2631 description: Option<&Entity<Markdown>>,
2632 configuration_view: Option<&AnyView>,
2633 pending_auth_method: Option<&acp::AuthMethodId>,
2634 window: &mut Window,
2635 cx: &Context<Self>,
2636 ) -> Div {
2637 v_flex()
2638 .p_2()
2639 .gap_2()
2640 .flex_1()
2641 .items_center()
2642 .justify_center()
2643 .child(
2644 v_flex()
2645 .items_center()
2646 .justify_center()
2647 .child(self.render_error_agent_logo())
2648 .child(
2649 h_flex().mt_4().mb_1().justify_center().child(
2650 Headline::new("Authentication Required").size(HeadlineSize::Medium),
2651 ),
2652 )
2653 .into_any(),
2654 )
2655 .children(description.map(|desc| {
2656 div().text_ui(cx).text_center().child(self.render_markdown(
2657 desc.clone(),
2658 default_markdown_style(false, false, window, cx),
2659 ))
2660 }))
2661 .children(
2662 configuration_view
2663 .cloned()
2664 .map(|view| div().px_4().w_full().max_w_128().child(view)),
2665 )
2666 .when(
2667 configuration_view.is_none()
2668 && description.is_none()
2669 && pending_auth_method.is_none(),
2670 |el| {
2671 el.child(
2672 div()
2673 .text_ui(cx)
2674 .text_center()
2675 .px_4()
2676 .w_full()
2677 .max_w_128()
2678 .child(Label::new("Authentication required")),
2679 )
2680 },
2681 )
2682 .when_some(pending_auth_method, |el, _| {
2683 let spinner_icon = div()
2684 .px_0p5()
2685 .id("generating")
2686 .tooltip(Tooltip::text("Generating Changes…"))
2687 .child(
2688 Icon::new(IconName::ArrowCircle)
2689 .size(IconSize::Small)
2690 .with_animation(
2691 "arrow-circle",
2692 Animation::new(Duration::from_secs(2)).repeat(),
2693 |icon, delta| {
2694 icon.transform(Transformation::rotate(percentage(delta)))
2695 },
2696 )
2697 .into_any_element(),
2698 )
2699 .into_any();
2700 el.child(
2701 h_flex()
2702 .text_ui(cx)
2703 .text_center()
2704 .justify_center()
2705 .gap_2()
2706 .px_4()
2707 .w_full()
2708 .max_w_128()
2709 .child(Label::new("Authenticating..."))
2710 .child(spinner_icon),
2711 )
2712 })
2713 .child(
2714 h_flex()
2715 .mt_1p5()
2716 .gap_1()
2717 .flex_wrap()
2718 .justify_center()
2719 .children(connection.auth_methods().iter().enumerate().rev().map(
2720 |(ix, method)| {
2721 Button::new(
2722 SharedString::from(method.id.0.clone()),
2723 method.name.clone(),
2724 )
2725 .style(ButtonStyle::Outlined)
2726 .when(ix == 0, |el| {
2727 el.style(ButtonStyle::Tinted(ui::TintColor::Accent))
2728 })
2729 .size(ButtonSize::Medium)
2730 .label_size(LabelSize::Small)
2731 .on_click({
2732 let method_id = method.id.clone();
2733 cx.listener(move |this, _, window, cx| {
2734 this.authenticate(method_id.clone(), window, cx)
2735 })
2736 })
2737 },
2738 )),
2739 )
2740 }
2741
2742 fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
2743 let mut container = v_flex()
2744 .items_center()
2745 .justify_center()
2746 .child(self.render_error_agent_logo())
2747 .child(
2748 v_flex()
2749 .mt_4()
2750 .mb_2()
2751 .gap_0p5()
2752 .text_center()
2753 .items_center()
2754 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
2755 .child(
2756 Label::new(e.to_string())
2757 .size(LabelSize::Small)
2758 .color(Color::Muted),
2759 ),
2760 );
2761
2762 if let LoadError::Unsupported {
2763 upgrade_message,
2764 upgrade_command,
2765 ..
2766 } = &e
2767 {
2768 let upgrade_message = upgrade_message.clone();
2769 let upgrade_command = upgrade_command.clone();
2770 container = container.child(
2771 Button::new("upgrade", upgrade_message)
2772 .tooltip(Tooltip::text(upgrade_command.clone()))
2773 .on_click(cx.listener(move |this, _, window, cx| {
2774 let task = this
2775 .workspace
2776 .update(cx, |workspace, cx| {
2777 let project = workspace.project().read(cx);
2778 let cwd = project.first_project_directory(cx);
2779 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2780 let spawn_in_terminal = task::SpawnInTerminal {
2781 id: task::TaskId("upgrade".to_string()),
2782 full_label: upgrade_command.clone(),
2783 label: upgrade_command.clone(),
2784 command: Some(upgrade_command.clone()),
2785 args: Vec::new(),
2786 command_label: upgrade_command.clone(),
2787 cwd,
2788 env: Default::default(),
2789 use_new_terminal: true,
2790 allow_concurrent_runs: true,
2791 reveal: Default::default(),
2792 reveal_target: Default::default(),
2793 hide: Default::default(),
2794 shell,
2795 show_summary: true,
2796 show_command: true,
2797 show_rerun: false,
2798 };
2799 workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
2800 })
2801 .ok();
2802 let Some(task) = task else { return };
2803 cx.spawn_in(window, async move |this, cx| {
2804 if let Some(Ok(_)) = task.await {
2805 this.update_in(cx, |this, window, cx| {
2806 this.reset(window, cx);
2807 })
2808 .ok();
2809 }
2810 })
2811 .detach()
2812 })),
2813 );
2814 } else if let LoadError::NotInstalled {
2815 install_message,
2816 install_command,
2817 ..
2818 } = e
2819 {
2820 let install_message = install_message.clone();
2821 let install_command = install_command.clone();
2822 container = container.child(
2823 Button::new("install", install_message)
2824 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
2825 .size(ButtonSize::Medium)
2826 .tooltip(Tooltip::text(install_command.clone()))
2827 .on_click(cx.listener(move |this, _, window, cx| {
2828 let task = this
2829 .workspace
2830 .update(cx, |workspace, cx| {
2831 let project = workspace.project().read(cx);
2832 let cwd = project.first_project_directory(cx);
2833 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2834 let spawn_in_terminal = task::SpawnInTerminal {
2835 id: task::TaskId("install".to_string()),
2836 full_label: install_command.clone(),
2837 label: install_command.clone(),
2838 command: Some(install_command.clone()),
2839 args: Vec::new(),
2840 command_label: install_command.clone(),
2841 cwd,
2842 env: Default::default(),
2843 use_new_terminal: true,
2844 allow_concurrent_runs: true,
2845 reveal: Default::default(),
2846 reveal_target: Default::default(),
2847 hide: Default::default(),
2848 shell,
2849 show_summary: true,
2850 show_command: true,
2851 show_rerun: false,
2852 };
2853 workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
2854 })
2855 .ok();
2856 let Some(task) = task else { return };
2857 cx.spawn_in(window, async move |this, cx| {
2858 if let Some(Ok(_)) = task.await {
2859 this.update_in(cx, |this, window, cx| {
2860 this.reset(window, cx);
2861 })
2862 .ok();
2863 }
2864 })
2865 .detach()
2866 })),
2867 );
2868 }
2869
2870 container.into_any()
2871 }
2872
2873 fn render_activity_bar(
2874 &self,
2875 thread_entity: &Entity<AcpThread>,
2876 window: &mut Window,
2877 cx: &Context<Self>,
2878 ) -> Option<AnyElement> {
2879 let thread = thread_entity.read(cx);
2880 let action_log = thread.action_log();
2881 let changed_buffers = action_log.read(cx).changed_buffers(cx);
2882 let plan = thread.plan();
2883
2884 if changed_buffers.is_empty() && plan.is_empty() {
2885 return None;
2886 }
2887
2888 let editor_bg_color = cx.theme().colors().editor_background;
2889 let active_color = cx.theme().colors().element_selected;
2890 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
2891
2892 let pending_edits = thread.has_pending_edit_tool_calls();
2893
2894 v_flex()
2895 .mt_1()
2896 .mx_2()
2897 .bg(bg_edit_files_disclosure)
2898 .border_1()
2899 .border_b_0()
2900 .border_color(cx.theme().colors().border)
2901 .rounded_t_md()
2902 .shadow(vec![gpui::BoxShadow {
2903 color: gpui::black().opacity(0.15),
2904 offset: point(px(1.), px(-1.)),
2905 blur_radius: px(3.),
2906 spread_radius: px(0.),
2907 }])
2908 .when(!plan.is_empty(), |this| {
2909 this.child(self.render_plan_summary(plan, window, cx))
2910 .when(self.plan_expanded, |parent| {
2911 parent.child(self.render_plan_entries(plan, window, cx))
2912 })
2913 })
2914 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2915 this.child(Divider::horizontal().color(DividerColor::Border))
2916 })
2917 .when(!changed_buffers.is_empty(), |this| {
2918 this.child(self.render_edits_summary(
2919 &changed_buffers,
2920 self.edits_expanded,
2921 pending_edits,
2922 window,
2923 cx,
2924 ))
2925 .when(self.edits_expanded, |parent| {
2926 parent.child(self.render_edited_files(
2927 action_log,
2928 &changed_buffers,
2929 pending_edits,
2930 cx,
2931 ))
2932 })
2933 })
2934 .into_any()
2935 .into()
2936 }
2937
2938 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2939 let stats = plan.stats();
2940
2941 let title = if let Some(entry) = stats.in_progress_entry
2942 && !self.plan_expanded
2943 {
2944 h_flex()
2945 .w_full()
2946 .cursor_default()
2947 .gap_1()
2948 .text_xs()
2949 .text_color(cx.theme().colors().text_muted)
2950 .justify_between()
2951 .child(
2952 h_flex()
2953 .gap_1()
2954 .child(
2955 Label::new("Current:")
2956 .size(LabelSize::Small)
2957 .color(Color::Muted),
2958 )
2959 .child(MarkdownElement::new(
2960 entry.content.clone(),
2961 plan_label_markdown_style(&entry.status, window, cx),
2962 )),
2963 )
2964 .when(stats.pending > 0, |this| {
2965 this.child(
2966 Label::new(format!("{} left", stats.pending))
2967 .size(LabelSize::Small)
2968 .color(Color::Muted)
2969 .mr_1(),
2970 )
2971 })
2972 } else {
2973 let status_label = if stats.pending == 0 {
2974 "All Done".to_string()
2975 } else if stats.completed == 0 {
2976 format!("{} Tasks", plan.entries.len())
2977 } else {
2978 format!("{}/{}", stats.completed, plan.entries.len())
2979 };
2980
2981 h_flex()
2982 .w_full()
2983 .gap_1()
2984 .justify_between()
2985 .child(
2986 Label::new("Plan")
2987 .size(LabelSize::Small)
2988 .color(Color::Muted),
2989 )
2990 .child(
2991 Label::new(status_label)
2992 .size(LabelSize::Small)
2993 .color(Color::Muted)
2994 .mr_1(),
2995 )
2996 };
2997
2998 h_flex()
2999 .p_1()
3000 .justify_between()
3001 .when(self.plan_expanded, |this| {
3002 this.border_b_1().border_color(cx.theme().colors().border)
3003 })
3004 .child(
3005 h_flex()
3006 .id("plan_summary")
3007 .w_full()
3008 .gap_1()
3009 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
3010 .child(title)
3011 .on_click(cx.listener(|this, _, _, cx| {
3012 this.plan_expanded = !this.plan_expanded;
3013 cx.notify();
3014 })),
3015 )
3016 }
3017
3018 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
3019 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
3020 let element = h_flex()
3021 .py_1()
3022 .px_2()
3023 .gap_2()
3024 .justify_between()
3025 .bg(cx.theme().colors().editor_background)
3026 .when(index < plan.entries.len() - 1, |parent| {
3027 parent.border_color(cx.theme().colors().border).border_b_1()
3028 })
3029 .child(
3030 h_flex()
3031 .id(("plan_entry", index))
3032 .gap_1p5()
3033 .max_w_full()
3034 .overflow_x_scroll()
3035 .text_xs()
3036 .text_color(cx.theme().colors().text_muted)
3037 .child(match entry.status {
3038 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
3039 .size(IconSize::Small)
3040 .color(Color::Muted)
3041 .into_any_element(),
3042 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
3043 .size(IconSize::Small)
3044 .color(Color::Accent)
3045 .with_animation(
3046 "running",
3047 Animation::new(Duration::from_secs(2)).repeat(),
3048 |icon, delta| {
3049 icon.transform(Transformation::rotate(percentage(delta)))
3050 },
3051 )
3052 .into_any_element(),
3053 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
3054 .size(IconSize::Small)
3055 .color(Color::Success)
3056 .into_any_element(),
3057 })
3058 .child(MarkdownElement::new(
3059 entry.content.clone(),
3060 plan_label_markdown_style(&entry.status, window, cx),
3061 )),
3062 );
3063
3064 Some(element)
3065 }))
3066 }
3067
3068 fn render_edits_summary(
3069 &self,
3070 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3071 expanded: bool,
3072 pending_edits: bool,
3073 window: &mut Window,
3074 cx: &Context<Self>,
3075 ) -> Div {
3076 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
3077
3078 let focus_handle = self.focus_handle(cx);
3079
3080 h_flex()
3081 .p_1()
3082 .justify_between()
3083 .when(expanded, |this| {
3084 this.border_b_1().border_color(cx.theme().colors().border)
3085 })
3086 .child(
3087 h_flex()
3088 .id("edits-container")
3089 .w_full()
3090 .gap_1()
3091 .child(Disclosure::new("edits-disclosure", expanded))
3092 .map(|this| {
3093 if pending_edits {
3094 this.child(
3095 Label::new(format!(
3096 "Editing {} {}…",
3097 changed_buffers.len(),
3098 if changed_buffers.len() == 1 {
3099 "file"
3100 } else {
3101 "files"
3102 }
3103 ))
3104 .color(Color::Muted)
3105 .size(LabelSize::Small)
3106 .with_animation(
3107 "edit-label",
3108 Animation::new(Duration::from_secs(2))
3109 .repeat()
3110 .with_easing(pulsating_between(0.3, 0.7)),
3111 |label, delta| label.alpha(delta),
3112 ),
3113 )
3114 } else {
3115 this.child(
3116 Label::new("Edits")
3117 .size(LabelSize::Small)
3118 .color(Color::Muted),
3119 )
3120 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
3121 .child(
3122 Label::new(format!(
3123 "{} {}",
3124 changed_buffers.len(),
3125 if changed_buffers.len() == 1 {
3126 "file"
3127 } else {
3128 "files"
3129 }
3130 ))
3131 .size(LabelSize::Small)
3132 .color(Color::Muted),
3133 )
3134 }
3135 })
3136 .on_click(cx.listener(|this, _, _, cx| {
3137 this.edits_expanded = !this.edits_expanded;
3138 cx.notify();
3139 })),
3140 )
3141 .child(
3142 h_flex()
3143 .gap_1()
3144 .child(
3145 IconButton::new("review-changes", IconName::ListTodo)
3146 .icon_size(IconSize::Small)
3147 .tooltip({
3148 let focus_handle = focus_handle.clone();
3149 move |window, cx| {
3150 Tooltip::for_action_in(
3151 "Review Changes",
3152 &OpenAgentDiff,
3153 &focus_handle,
3154 window,
3155 cx,
3156 )
3157 }
3158 })
3159 .on_click(cx.listener(|_, _, window, cx| {
3160 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
3161 })),
3162 )
3163 .child(Divider::vertical().color(DividerColor::Border))
3164 .child(
3165 Button::new("reject-all-changes", "Reject All")
3166 .label_size(LabelSize::Small)
3167 .disabled(pending_edits)
3168 .when(pending_edits, |this| {
3169 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3170 })
3171 .key_binding(
3172 KeyBinding::for_action_in(
3173 &RejectAll,
3174 &focus_handle.clone(),
3175 window,
3176 cx,
3177 )
3178 .map(|kb| kb.size(rems_from_px(10.))),
3179 )
3180 .on_click(cx.listener(move |this, _, window, cx| {
3181 this.reject_all(&RejectAll, window, cx);
3182 })),
3183 )
3184 .child(
3185 Button::new("keep-all-changes", "Keep All")
3186 .label_size(LabelSize::Small)
3187 .disabled(pending_edits)
3188 .when(pending_edits, |this| {
3189 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3190 })
3191 .key_binding(
3192 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
3193 .map(|kb| kb.size(rems_from_px(10.))),
3194 )
3195 .on_click(cx.listener(move |this, _, window, cx| {
3196 this.keep_all(&KeepAll, window, cx);
3197 })),
3198 ),
3199 )
3200 }
3201
3202 fn render_edited_files(
3203 &self,
3204 action_log: &Entity<ActionLog>,
3205 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3206 pending_edits: bool,
3207 cx: &Context<Self>,
3208 ) -> Div {
3209 let editor_bg_color = cx.theme().colors().editor_background;
3210
3211 v_flex().children(changed_buffers.iter().enumerate().flat_map(
3212 |(index, (buffer, _diff))| {
3213 let file = buffer.read(cx).file()?;
3214 let path = file.path();
3215
3216 let file_path = path.parent().and_then(|parent| {
3217 let parent_str = parent.to_string_lossy();
3218
3219 if parent_str.is_empty() {
3220 None
3221 } else {
3222 Some(
3223 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
3224 .color(Color::Muted)
3225 .size(LabelSize::XSmall)
3226 .buffer_font(cx),
3227 )
3228 }
3229 });
3230
3231 let file_name = path.file_name().map(|name| {
3232 Label::new(name.to_string_lossy().to_string())
3233 .size(LabelSize::XSmall)
3234 .buffer_font(cx)
3235 });
3236
3237 let file_icon = FileIcons::get_icon(path, cx)
3238 .map(Icon::from_path)
3239 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
3240 .unwrap_or_else(|| {
3241 Icon::new(IconName::File)
3242 .color(Color::Muted)
3243 .size(IconSize::Small)
3244 });
3245
3246 let overlay_gradient = linear_gradient(
3247 90.,
3248 linear_color_stop(editor_bg_color, 1.),
3249 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
3250 );
3251
3252 let element = h_flex()
3253 .group("edited-code")
3254 .id(("file-container", index))
3255 .relative()
3256 .py_1()
3257 .pl_2()
3258 .pr_1()
3259 .gap_2()
3260 .justify_between()
3261 .bg(editor_bg_color)
3262 .when(index < changed_buffers.len() - 1, |parent| {
3263 parent.border_color(cx.theme().colors().border).border_b_1()
3264 })
3265 .child(
3266 h_flex()
3267 .id(("file-name", index))
3268 .pr_8()
3269 .gap_1p5()
3270 .max_w_full()
3271 .overflow_x_scroll()
3272 .child(file_icon)
3273 .child(h_flex().gap_0p5().children(file_name).children(file_path))
3274 .on_click({
3275 let buffer = buffer.clone();
3276 cx.listener(move |this, _, window, cx| {
3277 this.open_edited_buffer(&buffer, window, cx);
3278 })
3279 }),
3280 )
3281 .child(
3282 h_flex()
3283 .gap_1()
3284 .visible_on_hover("edited-code")
3285 .child(
3286 Button::new("review", "Review")
3287 .label_size(LabelSize::Small)
3288 .on_click({
3289 let buffer = buffer.clone();
3290 cx.listener(move |this, _, window, cx| {
3291 this.open_edited_buffer(&buffer, window, cx);
3292 })
3293 }),
3294 )
3295 .child(Divider::vertical().color(DividerColor::BorderVariant))
3296 .child(
3297 Button::new("reject-file", "Reject")
3298 .label_size(LabelSize::Small)
3299 .disabled(pending_edits)
3300 .on_click({
3301 let buffer = buffer.clone();
3302 let action_log = action_log.clone();
3303 move |_, _, cx| {
3304 action_log.update(cx, |action_log, cx| {
3305 action_log
3306 .reject_edits_in_ranges(
3307 buffer.clone(),
3308 vec![Anchor::MIN..Anchor::MAX],
3309 cx,
3310 )
3311 .detach_and_log_err(cx);
3312 })
3313 }
3314 }),
3315 )
3316 .child(
3317 Button::new("keep-file", "Keep")
3318 .label_size(LabelSize::Small)
3319 .disabled(pending_edits)
3320 .on_click({
3321 let buffer = buffer.clone();
3322 let action_log = action_log.clone();
3323 move |_, _, cx| {
3324 action_log.update(cx, |action_log, cx| {
3325 action_log.keep_edits_in_range(
3326 buffer.clone(),
3327 Anchor::MIN..Anchor::MAX,
3328 cx,
3329 );
3330 })
3331 }
3332 }),
3333 ),
3334 )
3335 .child(
3336 div()
3337 .id("gradient-overlay")
3338 .absolute()
3339 .h_full()
3340 .w_12()
3341 .top_0()
3342 .bottom_0()
3343 .right(px(152.))
3344 .bg(overlay_gradient),
3345 );
3346
3347 Some(element)
3348 },
3349 ))
3350 }
3351
3352 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
3353 let focus_handle = self.message_editor.focus_handle(cx);
3354 let editor_bg_color = cx.theme().colors().editor_background;
3355 let (expand_icon, expand_tooltip) = if self.editor_expanded {
3356 (IconName::Minimize, "Minimize Message Editor")
3357 } else {
3358 (IconName::Maximize, "Expand Message Editor")
3359 };
3360
3361 v_flex()
3362 .on_action(cx.listener(Self::expand_message_editor))
3363 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
3364 if let Some(profile_selector) = this.profile_selector.as_ref() {
3365 profile_selector.read(cx).menu_handle().toggle(window, cx);
3366 }
3367 }))
3368 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
3369 if let Some(model_selector) = this.model_selector.as_ref() {
3370 model_selector
3371 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
3372 }
3373 }))
3374 .p_2()
3375 .gap_2()
3376 .border_t_1()
3377 .border_color(cx.theme().colors().border)
3378 .bg(editor_bg_color)
3379 .when(self.editor_expanded, |this| {
3380 this.h(vh(0.8, window)).size_full().justify_between()
3381 })
3382 .child(
3383 v_flex()
3384 .relative()
3385 .size_full()
3386 .pt_1()
3387 .pr_2p5()
3388 .child(self.message_editor.clone())
3389 .child(
3390 h_flex()
3391 .absolute()
3392 .top_0()
3393 .right_0()
3394 .opacity(0.5)
3395 .hover(|this| this.opacity(1.0))
3396 .child(
3397 IconButton::new("toggle-height", expand_icon)
3398 .icon_size(IconSize::Small)
3399 .icon_color(Color::Muted)
3400 .tooltip({
3401 move |window, cx| {
3402 Tooltip::for_action_in(
3403 expand_tooltip,
3404 &ExpandMessageEditor,
3405 &focus_handle,
3406 window,
3407 cx,
3408 )
3409 }
3410 })
3411 .on_click(cx.listener(|_, _, window, cx| {
3412 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
3413 })),
3414 ),
3415 ),
3416 )
3417 .child(
3418 h_flex()
3419 .flex_none()
3420 .flex_wrap()
3421 .justify_between()
3422 .child(
3423 h_flex()
3424 .child(self.render_follow_toggle(cx))
3425 .children(self.render_burn_mode_toggle(cx)),
3426 )
3427 .child(
3428 h_flex()
3429 .gap_1()
3430 .children(self.render_token_usage(cx))
3431 .children(self.profile_selector.clone())
3432 .children(self.model_selector.clone())
3433 .child(self.render_send_button(cx)),
3434 ),
3435 )
3436 .into_any()
3437 }
3438
3439 pub(crate) fn as_native_connection(
3440 &self,
3441 cx: &App,
3442 ) -> Option<Rc<agent2::NativeAgentConnection>> {
3443 let acp_thread = self.thread()?.read(cx);
3444 acp_thread.connection().clone().downcast()
3445 }
3446
3447 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
3448 let acp_thread = self.thread()?.read(cx);
3449 self.as_native_connection(cx)?
3450 .thread(acp_thread.session_id(), cx)
3451 }
3452
3453 fn is_using_zed_ai_models(&self, cx: &App) -> bool {
3454 self.as_native_thread(cx)
3455 .and_then(|thread| thread.read(cx).model())
3456 .is_some_and(|model| model.provider_id() == language_model::ZED_CLOUD_PROVIDER_ID)
3457 }
3458
3459 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<Div> {
3460 let thread = self.thread()?.read(cx);
3461 let usage = thread.token_usage()?;
3462 let is_generating = thread.status() != ThreadStatus::Idle;
3463
3464 let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
3465 let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
3466
3467 Some(
3468 h_flex()
3469 .flex_shrink_0()
3470 .gap_0p5()
3471 .mr_1p5()
3472 .child(
3473 Label::new(used)
3474 .size(LabelSize::Small)
3475 .color(Color::Muted)
3476 .map(|label| {
3477 if is_generating {
3478 label
3479 .with_animation(
3480 "used-tokens-label",
3481 Animation::new(Duration::from_secs(2))
3482 .repeat()
3483 .with_easing(pulsating_between(0.3, 0.8)),
3484 |label, delta| label.alpha(delta),
3485 )
3486 .into_any()
3487 } else {
3488 label.into_any_element()
3489 }
3490 }),
3491 )
3492 .child(
3493 Label::new("/")
3494 .size(LabelSize::Small)
3495 .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
3496 )
3497 .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
3498 )
3499 }
3500
3501 fn toggle_burn_mode(
3502 &mut self,
3503 _: &ToggleBurnMode,
3504 _window: &mut Window,
3505 cx: &mut Context<Self>,
3506 ) {
3507 let Some(thread) = self.as_native_thread(cx) else {
3508 return;
3509 };
3510
3511 thread.update(cx, |thread, cx| {
3512 let current_mode = thread.completion_mode();
3513 thread.set_completion_mode(
3514 match current_mode {
3515 CompletionMode::Burn => CompletionMode::Normal,
3516 CompletionMode::Normal => CompletionMode::Burn,
3517 },
3518 cx,
3519 );
3520 });
3521 }
3522
3523 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
3524 let Some(thread) = self.thread() else {
3525 return;
3526 };
3527 let action_log = thread.read(cx).action_log().clone();
3528 action_log.update(cx, |action_log, cx| action_log.keep_all_edits(cx));
3529 }
3530
3531 fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
3532 let Some(thread) = self.thread() else {
3533 return;
3534 };
3535 let action_log = thread.read(cx).action_log().clone();
3536 action_log
3537 .update(cx, |action_log, cx| action_log.reject_all_edits(cx))
3538 .detach();
3539 }
3540
3541 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3542 let thread = self.as_native_thread(cx)?.read(cx);
3543
3544 if thread
3545 .model()
3546 .is_none_or(|model| !model.supports_burn_mode())
3547 {
3548 return None;
3549 }
3550
3551 let active_completion_mode = thread.completion_mode();
3552 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
3553 let icon = if burn_mode_enabled {
3554 IconName::ZedBurnModeOn
3555 } else {
3556 IconName::ZedBurnMode
3557 };
3558
3559 Some(
3560 IconButton::new("burn-mode", icon)
3561 .icon_size(IconSize::Small)
3562 .icon_color(Color::Muted)
3563 .toggle_state(burn_mode_enabled)
3564 .selected_icon_color(Color::Error)
3565 .on_click(cx.listener(|this, _event, window, cx| {
3566 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
3567 }))
3568 .tooltip(move |_window, cx| {
3569 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
3570 .into()
3571 })
3572 .into_any_element(),
3573 )
3574 }
3575
3576 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3577 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
3578 let is_generating = self
3579 .thread()
3580 .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
3581
3582 if is_generating && is_editor_empty {
3583 IconButton::new("stop-generation", IconName::Stop)
3584 .icon_color(Color::Error)
3585 .style(ButtonStyle::Tinted(ui::TintColor::Error))
3586 .tooltip(move |window, cx| {
3587 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
3588 })
3589 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3590 .into_any_element()
3591 } else {
3592 let send_btn_tooltip = if is_editor_empty && !is_generating {
3593 "Type to Send"
3594 } else if is_generating {
3595 "Stop and Send Message"
3596 } else {
3597 "Send"
3598 };
3599
3600 IconButton::new("send-message", IconName::Send)
3601 .style(ButtonStyle::Filled)
3602 .map(|this| {
3603 if is_editor_empty && !is_generating {
3604 this.disabled(true).icon_color(Color::Muted)
3605 } else {
3606 this.icon_color(Color::Accent)
3607 }
3608 })
3609 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
3610 .on_click(cx.listener(|this, _, window, cx| {
3611 this.send(window, cx);
3612 }))
3613 .into_any_element()
3614 }
3615 }
3616
3617 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
3618 let following = self
3619 .workspace
3620 .read_with(cx, |workspace, _| {
3621 workspace.is_being_followed(CollaboratorId::Agent)
3622 })
3623 .unwrap_or(false);
3624
3625 IconButton::new("follow-agent", IconName::Crosshair)
3626 .icon_size(IconSize::Small)
3627 .icon_color(Color::Muted)
3628 .toggle_state(following)
3629 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
3630 .tooltip(move |window, cx| {
3631 if following {
3632 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
3633 } else {
3634 Tooltip::with_meta(
3635 "Follow Agent",
3636 Some(&Follow),
3637 "Track the agent's location as it reads and edits files.",
3638 window,
3639 cx,
3640 )
3641 }
3642 })
3643 .on_click(cx.listener(move |this, _, window, cx| {
3644 this.workspace
3645 .update(cx, |workspace, cx| {
3646 if following {
3647 workspace.unfollow(CollaboratorId::Agent, window, cx);
3648 } else {
3649 workspace.follow(CollaboratorId::Agent, window, cx);
3650 }
3651 })
3652 .ok();
3653 }))
3654 }
3655
3656 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
3657 let workspace = self.workspace.clone();
3658 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
3659 Self::open_link(text, &workspace, window, cx);
3660 })
3661 }
3662
3663 fn open_link(
3664 url: SharedString,
3665 workspace: &WeakEntity<Workspace>,
3666 window: &mut Window,
3667 cx: &mut App,
3668 ) {
3669 let Some(workspace) = workspace.upgrade() else {
3670 cx.open_url(&url);
3671 return;
3672 };
3673
3674 if let Some(mention) = MentionUri::parse(&url).log_err() {
3675 workspace.update(cx, |workspace, cx| match mention {
3676 MentionUri::File { abs_path } => {
3677 let project = workspace.project();
3678 let Some(path) =
3679 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
3680 else {
3681 return;
3682 };
3683
3684 workspace
3685 .open_path(path, None, true, window, cx)
3686 .detach_and_log_err(cx);
3687 }
3688 MentionUri::Directory { abs_path } => {
3689 let project = workspace.project();
3690 let Some(entry) = project.update(cx, |project, cx| {
3691 let path = project.find_project_path(abs_path, cx)?;
3692 project.entry_for_path(&path, cx)
3693 }) else {
3694 return;
3695 };
3696
3697 project.update(cx, |_, cx| {
3698 cx.emit(project::Event::RevealInProjectPanel(entry.id));
3699 });
3700 }
3701 MentionUri::Symbol {
3702 path, line_range, ..
3703 }
3704 | MentionUri::Selection { path, line_range } => {
3705 let project = workspace.project();
3706 let Some((path, _)) = project.update(cx, |project, cx| {
3707 let path = project.find_project_path(path, cx)?;
3708 let entry = project.entry_for_path(&path, cx)?;
3709 Some((path, entry))
3710 }) else {
3711 return;
3712 };
3713
3714 let item = workspace.open_path(path, None, true, window, cx);
3715 window
3716 .spawn(cx, async move |cx| {
3717 let Some(editor) = item.await?.downcast::<Editor>() else {
3718 return Ok(());
3719 };
3720 let range =
3721 Point::new(line_range.start, 0)..Point::new(line_range.start, 0);
3722 editor
3723 .update_in(cx, |editor, window, cx| {
3724 editor.change_selections(
3725 SelectionEffects::scroll(Autoscroll::center()),
3726 window,
3727 cx,
3728 |s| s.select_ranges(vec![range]),
3729 );
3730 })
3731 .ok();
3732 anyhow::Ok(())
3733 })
3734 .detach_and_log_err(cx);
3735 }
3736 MentionUri::Thread { id, name } => {
3737 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3738 panel.update(cx, |panel, cx| {
3739 panel.load_agent_thread(
3740 DbThreadMetadata {
3741 id,
3742 title: name.into(),
3743 updated_at: Default::default(),
3744 },
3745 window,
3746 cx,
3747 )
3748 });
3749 }
3750 }
3751 MentionUri::TextThread { path, .. } => {
3752 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3753 panel.update(cx, |panel, cx| {
3754 panel
3755 .open_saved_prompt_editor(path.as_path().into(), window, cx)
3756 .detach_and_log_err(cx);
3757 });
3758 }
3759 }
3760 MentionUri::Rule { id, .. } => {
3761 let PromptId::User { uuid } = id else {
3762 return;
3763 };
3764 window.dispatch_action(
3765 Box::new(OpenRulesLibrary {
3766 prompt_to_select: Some(uuid.0),
3767 }),
3768 cx,
3769 )
3770 }
3771 MentionUri::Fetch { url } => {
3772 cx.open_url(url.as_str());
3773 }
3774 })
3775 } else {
3776 cx.open_url(&url);
3777 }
3778 }
3779
3780 fn open_tool_call_location(
3781 &self,
3782 entry_ix: usize,
3783 location_ix: usize,
3784 window: &mut Window,
3785 cx: &mut Context<Self>,
3786 ) -> Option<()> {
3787 let (tool_call_location, agent_location) = self
3788 .thread()?
3789 .read(cx)
3790 .entries()
3791 .get(entry_ix)?
3792 .location(location_ix)?;
3793
3794 let project_path = self
3795 .project
3796 .read(cx)
3797 .find_project_path(&tool_call_location.path, cx)?;
3798
3799 let open_task = self
3800 .workspace
3801 .update(cx, |workspace, cx| {
3802 workspace.open_path(project_path, None, true, window, cx)
3803 })
3804 .log_err()?;
3805 window
3806 .spawn(cx, async move |cx| {
3807 let item = open_task.await?;
3808
3809 let Some(active_editor) = item.downcast::<Editor>() else {
3810 return anyhow::Ok(());
3811 };
3812
3813 active_editor.update_in(cx, |editor, window, cx| {
3814 let multibuffer = editor.buffer().read(cx);
3815 let buffer = multibuffer.as_singleton();
3816 if agent_location.buffer.upgrade() == buffer {
3817 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
3818 let anchor = editor::Anchor::in_buffer(
3819 excerpt_id.unwrap(),
3820 buffer.unwrap().read(cx).remote_id(),
3821 agent_location.position,
3822 );
3823 editor.change_selections(Default::default(), window, cx, |selections| {
3824 selections.select_anchor_ranges([anchor..anchor]);
3825 })
3826 } else {
3827 let row = tool_call_location.line.unwrap_or_default();
3828 editor.change_selections(Default::default(), window, cx, |selections| {
3829 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
3830 })
3831 }
3832 })?;
3833
3834 anyhow::Ok(())
3835 })
3836 .detach_and_log_err(cx);
3837
3838 None
3839 }
3840
3841 pub fn open_thread_as_markdown(
3842 &self,
3843 workspace: Entity<Workspace>,
3844 window: &mut Window,
3845 cx: &mut App,
3846 ) -> Task<anyhow::Result<()>> {
3847 let markdown_language_task = workspace
3848 .read(cx)
3849 .app_state()
3850 .languages
3851 .language_for_name("Markdown");
3852
3853 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
3854 let thread = thread.read(cx);
3855 (thread.title().to_string(), thread.to_markdown(cx))
3856 } else {
3857 return Task::ready(Ok(()));
3858 };
3859
3860 window.spawn(cx, async move |cx| {
3861 let markdown_language = markdown_language_task.await?;
3862
3863 workspace.update_in(cx, |workspace, window, cx| {
3864 let project = workspace.project().clone();
3865
3866 if !project.read(cx).is_local() {
3867 bail!("failed to open active thread as markdown in remote project");
3868 }
3869
3870 let buffer = project.update(cx, |project, cx| {
3871 project.create_local_buffer(&markdown, Some(markdown_language), cx)
3872 });
3873 let buffer = cx.new(|cx| {
3874 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
3875 });
3876
3877 workspace.add_item_to_active_pane(
3878 Box::new(cx.new(|cx| {
3879 let mut editor =
3880 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
3881 editor.set_breadcrumb_header(thread_summary);
3882 editor
3883 })),
3884 None,
3885 true,
3886 window,
3887 cx,
3888 );
3889
3890 anyhow::Ok(())
3891 })??;
3892 anyhow::Ok(())
3893 })
3894 }
3895
3896 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
3897 self.list_state.scroll_to(ListOffset::default());
3898 cx.notify();
3899 }
3900
3901 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
3902 if let Some(thread) = self.thread() {
3903 let entry_count = thread.read(cx).entries().len();
3904 self.list_state.reset(entry_count);
3905 cx.notify();
3906 }
3907 }
3908
3909 fn notify_with_sound(
3910 &mut self,
3911 caption: impl Into<SharedString>,
3912 icon: IconName,
3913 window: &mut Window,
3914 cx: &mut Context<Self>,
3915 ) {
3916 self.play_notification_sound(window, cx);
3917 self.show_notification(caption, icon, window, cx);
3918 }
3919
3920 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
3921 let settings = AgentSettings::get_global(cx);
3922 if settings.play_sound_when_agent_done && !window.is_window_active() {
3923 Audio::play_sound(Sound::AgentDone, cx);
3924 }
3925 }
3926
3927 fn show_notification(
3928 &mut self,
3929 caption: impl Into<SharedString>,
3930 icon: IconName,
3931 window: &mut Window,
3932 cx: &mut Context<Self>,
3933 ) {
3934 if window.is_window_active() || !self.notifications.is_empty() {
3935 return;
3936 }
3937
3938 let title = self.title(cx);
3939
3940 match AgentSettings::get_global(cx).notify_when_agent_waiting {
3941 NotifyWhenAgentWaiting::PrimaryScreen => {
3942 if let Some(primary) = cx.primary_display() {
3943 self.pop_up(icon, caption.into(), title, window, primary, cx);
3944 }
3945 }
3946 NotifyWhenAgentWaiting::AllScreens => {
3947 let caption = caption.into();
3948 for screen in cx.displays() {
3949 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
3950 }
3951 }
3952 NotifyWhenAgentWaiting::Never => {
3953 // Don't show anything
3954 }
3955 }
3956 }
3957
3958 fn pop_up(
3959 &mut self,
3960 icon: IconName,
3961 caption: SharedString,
3962 title: SharedString,
3963 window: &mut Window,
3964 screen: Rc<dyn PlatformDisplay>,
3965 cx: &mut Context<Self>,
3966 ) {
3967 let options = AgentNotification::window_options(screen, cx);
3968
3969 let project_name = self.workspace.upgrade().and_then(|workspace| {
3970 workspace
3971 .read(cx)
3972 .project()
3973 .read(cx)
3974 .visible_worktrees(cx)
3975 .next()
3976 .map(|worktree| worktree.read(cx).root_name().to_string())
3977 });
3978
3979 if let Some(screen_window) = cx
3980 .open_window(options, |_, cx| {
3981 cx.new(|_| {
3982 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
3983 })
3984 })
3985 .log_err()
3986 && let Some(pop_up) = screen_window.entity(cx).log_err()
3987 {
3988 self.notification_subscriptions
3989 .entry(screen_window)
3990 .or_insert_with(Vec::new)
3991 .push(cx.subscribe_in(&pop_up, window, {
3992 |this, _, event, window, cx| match event {
3993 AgentNotificationEvent::Accepted => {
3994 let handle = window.window_handle();
3995 cx.activate(true);
3996
3997 let workspace_handle = this.workspace.clone();
3998
3999 // If there are multiple Zed windows, activate the correct one.
4000 cx.defer(move |cx| {
4001 handle
4002 .update(cx, |_view, window, _cx| {
4003 window.activate_window();
4004
4005 if let Some(workspace) = workspace_handle.upgrade() {
4006 workspace.update(_cx, |workspace, cx| {
4007 workspace.focus_panel::<AgentPanel>(window, cx);
4008 });
4009 }
4010 })
4011 .log_err();
4012 });
4013
4014 this.dismiss_notifications(cx);
4015 }
4016 AgentNotificationEvent::Dismissed => {
4017 this.dismiss_notifications(cx);
4018 }
4019 }
4020 }));
4021
4022 self.notifications.push(screen_window);
4023
4024 // If the user manually refocuses the original window, dismiss the popup.
4025 self.notification_subscriptions
4026 .entry(screen_window)
4027 .or_insert_with(Vec::new)
4028 .push({
4029 let pop_up_weak = pop_up.downgrade();
4030
4031 cx.observe_window_activation(window, move |_, window, cx| {
4032 if window.is_window_active()
4033 && let Some(pop_up) = pop_up_weak.upgrade()
4034 {
4035 pop_up.update(cx, |_, cx| {
4036 cx.emit(AgentNotificationEvent::Dismissed);
4037 });
4038 }
4039 })
4040 });
4041 }
4042 }
4043
4044 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
4045 for window in self.notifications.drain(..) {
4046 window
4047 .update(cx, |_, window, _| {
4048 window.remove_window();
4049 })
4050 .ok();
4051
4052 self.notification_subscriptions.remove(&window);
4053 }
4054 }
4055
4056 fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement {
4057 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
4058 .shape(ui::IconButtonShape::Square)
4059 .icon_size(IconSize::Small)
4060 .icon_color(Color::Ignored)
4061 .tooltip(Tooltip::text("Open Thread as Markdown"))
4062 .on_click(cx.listener(move |this, _, window, cx| {
4063 if let Some(workspace) = this.workspace.upgrade() {
4064 this.open_thread_as_markdown(workspace, window, cx)
4065 .detach_and_log_err(cx);
4066 }
4067 }));
4068
4069 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
4070 .shape(ui::IconButtonShape::Square)
4071 .icon_size(IconSize::Small)
4072 .icon_color(Color::Ignored)
4073 .tooltip(Tooltip::text("Scroll To Top"))
4074 .on_click(cx.listener(move |this, _, _, cx| {
4075 this.scroll_to_top(cx);
4076 }));
4077
4078 let mut container = h_flex()
4079 .id("thread-controls-container")
4080 .group("thread-controls-container")
4081 .w_full()
4082 .mr_1()
4083 .pb_2()
4084 .px(RESPONSE_PADDING_X)
4085 .opacity(0.4)
4086 .hover(|style| style.opacity(1.))
4087 .flex_wrap()
4088 .justify_end();
4089
4090 if AgentSettings::get_global(cx).enable_feedback
4091 && self
4092 .thread()
4093 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
4094 {
4095 let feedback = self.thread_feedback.feedback;
4096 container = container.child(
4097 div().visible_on_hover("thread-controls-container").child(
4098 Label::new(
4099 match feedback {
4100 Some(ThreadFeedback::Positive) => "Thanks for your feedback!",
4101 Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.",
4102 None => "Rating the thread sends all of your current conversation to the Zed team.",
4103 }
4104 )
4105 .color(Color::Muted)
4106 .size(LabelSize::XSmall)
4107 .truncate(),
4108 ),
4109 ).child(
4110 h_flex()
4111 .child(
4112 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
4113 .shape(ui::IconButtonShape::Square)
4114 .icon_size(IconSize::Small)
4115 .icon_color(match feedback {
4116 Some(ThreadFeedback::Positive) => Color::Accent,
4117 _ => Color::Ignored,
4118 })
4119 .tooltip(Tooltip::text("Helpful Response"))
4120 .on_click(cx.listener(move |this, _, window, cx| {
4121 this.handle_feedback_click(
4122 ThreadFeedback::Positive,
4123 window,
4124 cx,
4125 );
4126 })),
4127 )
4128 .child(
4129 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
4130 .shape(ui::IconButtonShape::Square)
4131 .icon_size(IconSize::Small)
4132 .icon_color(match feedback {
4133 Some(ThreadFeedback::Negative) => Color::Accent,
4134 _ => Color::Ignored,
4135 })
4136 .tooltip(Tooltip::text("Not Helpful"))
4137 .on_click(cx.listener(move |this, _, window, cx| {
4138 this.handle_feedback_click(
4139 ThreadFeedback::Negative,
4140 window,
4141 cx,
4142 );
4143 })),
4144 )
4145 )
4146 }
4147
4148 container.child(open_as_markdown).child(scroll_to_top)
4149 }
4150
4151 fn render_feedback_feedback_editor(
4152 editor: Entity<Editor>,
4153 window: &mut Window,
4154 cx: &Context<Self>,
4155 ) -> Div {
4156 let focus_handle = editor.focus_handle(cx);
4157 v_flex()
4158 .key_context("AgentFeedbackMessageEditor")
4159 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
4160 this.thread_feedback.dismiss_comments();
4161 cx.notify();
4162 }))
4163 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
4164 this.submit_feedback_message(cx);
4165 }))
4166 .mb_2()
4167 .mx_4()
4168 .p_2()
4169 .rounded_md()
4170 .border_1()
4171 .border_color(cx.theme().colors().border)
4172 .bg(cx.theme().colors().editor_background)
4173 .child(editor)
4174 .child(
4175 h_flex()
4176 .gap_1()
4177 .justify_end()
4178 .child(
4179 Button::new("dismiss-feedback-message", "Cancel")
4180 .label_size(LabelSize::Small)
4181 .key_binding(
4182 KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx)
4183 .map(|kb| kb.size(rems_from_px(10.))),
4184 )
4185 .on_click(cx.listener(move |this, _, _window, cx| {
4186 this.thread_feedback.dismiss_comments();
4187 cx.notify();
4188 })),
4189 )
4190 .child(
4191 Button::new("submit-feedback-message", "Share Feedback")
4192 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4193 .label_size(LabelSize::Small)
4194 .key_binding(
4195 KeyBinding::for_action_in(
4196 &menu::Confirm,
4197 &focus_handle,
4198 window,
4199 cx,
4200 )
4201 .map(|kb| kb.size(rems_from_px(10.))),
4202 )
4203 .on_click(cx.listener(move |this, _, _window, cx| {
4204 this.submit_feedback_message(cx);
4205 })),
4206 ),
4207 )
4208 }
4209
4210 fn handle_feedback_click(
4211 &mut self,
4212 feedback: ThreadFeedback,
4213 window: &mut Window,
4214 cx: &mut Context<Self>,
4215 ) {
4216 let Some(thread) = self.thread().cloned() else {
4217 return;
4218 };
4219
4220 self.thread_feedback.submit(thread, feedback, window, cx);
4221 cx.notify();
4222 }
4223
4224 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4225 let Some(thread) = self.thread().cloned() else {
4226 return;
4227 };
4228
4229 self.thread_feedback.submit_comments(thread, cx);
4230 cx.notify();
4231 }
4232
4233 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
4234 div()
4235 .id("acp-thread-scrollbar")
4236 .occlude()
4237 .on_mouse_move(cx.listener(|_, _, _, cx| {
4238 cx.notify();
4239 cx.stop_propagation()
4240 }))
4241 .on_hover(|_, _, cx| {
4242 cx.stop_propagation();
4243 })
4244 .on_any_mouse_down(|_, _, cx| {
4245 cx.stop_propagation();
4246 })
4247 .on_mouse_up(
4248 MouseButton::Left,
4249 cx.listener(|_, _, _, cx| {
4250 cx.stop_propagation();
4251 }),
4252 )
4253 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4254 cx.notify();
4255 }))
4256 .h_full()
4257 .absolute()
4258 .right_1()
4259 .top_1()
4260 .bottom_0()
4261 .w(px(12.))
4262 .cursor_default()
4263 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
4264 }
4265
4266 fn render_token_limit_callout(
4267 &self,
4268 line_height: Pixels,
4269 cx: &mut Context<Self>,
4270 ) -> Option<Callout> {
4271 let token_usage = self.thread()?.read(cx).token_usage()?;
4272 let ratio = token_usage.ratio();
4273
4274 let (severity, title) = match ratio {
4275 acp_thread::TokenUsageRatio::Normal => return None,
4276 acp_thread::TokenUsageRatio::Warning => {
4277 (Severity::Warning, "Thread reaching the token limit soon")
4278 }
4279 acp_thread::TokenUsageRatio::Exceeded => {
4280 (Severity::Error, "Thread reached the token limit")
4281 }
4282 };
4283
4284 let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
4285 thread.read(cx).completion_mode() == CompletionMode::Normal
4286 && thread
4287 .read(cx)
4288 .model()
4289 .is_some_and(|model| model.supports_burn_mode())
4290 });
4291
4292 let description = if burn_mode_available {
4293 "To continue, start a new thread from a summary or turn Burn Mode on."
4294 } else {
4295 "To continue, start a new thread from a summary."
4296 };
4297
4298 Some(
4299 Callout::new()
4300 .severity(severity)
4301 .line_height(line_height)
4302 .title(title)
4303 .description(description)
4304 .actions_slot(
4305 h_flex()
4306 .gap_0p5()
4307 .child(
4308 Button::new("start-new-thread", "Start New Thread")
4309 .label_size(LabelSize::Small)
4310 .on_click(cx.listener(|this, _, window, cx| {
4311 let Some(thread) = this.thread() else {
4312 return;
4313 };
4314 let session_id = thread.read(cx).session_id().clone();
4315 window.dispatch_action(
4316 crate::NewNativeAgentThreadFromSummary {
4317 from_session_id: session_id,
4318 }
4319 .boxed_clone(),
4320 cx,
4321 );
4322 })),
4323 )
4324 .when(burn_mode_available, |this| {
4325 this.child(
4326 IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
4327 .icon_size(IconSize::XSmall)
4328 .on_click(cx.listener(|this, _event, window, cx| {
4329 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4330 })),
4331 )
4332 }),
4333 ),
4334 )
4335 }
4336
4337 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
4338 if !self.is_using_zed_ai_models(cx) {
4339 return None;
4340 }
4341
4342 let user_store = self.project.read(cx).user_store().read(cx);
4343 if user_store.is_usage_based_billing_enabled() {
4344 return None;
4345 }
4346
4347 let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
4348
4349 let usage = user_store.model_request_usage()?;
4350
4351 Some(
4352 div()
4353 .child(UsageCallout::new(plan, usage))
4354 .line_height(line_height),
4355 )
4356 }
4357
4358 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4359 self.entry_view_state.update(cx, |entry_view_state, cx| {
4360 entry_view_state.settings_changed(cx);
4361 });
4362 }
4363
4364 pub(crate) fn insert_dragged_files(
4365 &self,
4366 paths: Vec<project::ProjectPath>,
4367 added_worktrees: Vec<Entity<project::Worktree>>,
4368 window: &mut Window,
4369 cx: &mut Context<Self>,
4370 ) {
4371 self.message_editor.update(cx, |message_editor, cx| {
4372 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
4373 })
4374 }
4375
4376 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
4377 self.message_editor.update(cx, |message_editor, cx| {
4378 message_editor.insert_selections(window, cx);
4379 })
4380 }
4381
4382 fn render_thread_retry_status_callout(
4383 &self,
4384 _window: &mut Window,
4385 _cx: &mut Context<Self>,
4386 ) -> Option<Callout> {
4387 let state = self.thread_retry_status.as_ref()?;
4388
4389 let next_attempt_in = state
4390 .duration
4391 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
4392 if next_attempt_in.is_zero() {
4393 return None;
4394 }
4395
4396 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
4397
4398 let retry_message = if state.max_attempts == 1 {
4399 if next_attempt_in_secs == 1 {
4400 "Retrying. Next attempt in 1 second.".to_string()
4401 } else {
4402 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
4403 }
4404 } else if next_attempt_in_secs == 1 {
4405 format!(
4406 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
4407 state.attempt, state.max_attempts,
4408 )
4409 } else {
4410 format!(
4411 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
4412 state.attempt, state.max_attempts,
4413 )
4414 };
4415
4416 Some(
4417 Callout::new()
4418 .severity(Severity::Warning)
4419 .title(state.last_error.clone())
4420 .description(retry_message),
4421 )
4422 }
4423
4424 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
4425 let content = match self.thread_error.as_ref()? {
4426 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
4427 ThreadError::AuthenticationRequired(error) => {
4428 self.render_authentication_required_error(error.clone(), cx)
4429 }
4430 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
4431 ThreadError::ModelRequestLimitReached(plan) => {
4432 self.render_model_request_limit_reached_error(*plan, cx)
4433 }
4434 ThreadError::ToolUseLimitReached => {
4435 self.render_tool_use_limit_reached_error(window, cx)?
4436 }
4437 };
4438
4439 Some(div().child(content))
4440 }
4441
4442 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
4443 Callout::new()
4444 .severity(Severity::Error)
4445 .title("Error")
4446 .description(error.clone())
4447 .actions_slot(self.create_copy_button(error.to_string()))
4448 .dismiss_action(self.dismiss_error_button(cx))
4449 }
4450
4451 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
4452 const ERROR_MESSAGE: &str =
4453 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
4454
4455 Callout::new()
4456 .severity(Severity::Error)
4457 .title("Free Usage Exceeded")
4458 .description(ERROR_MESSAGE)
4459 .actions_slot(
4460 h_flex()
4461 .gap_0p5()
4462 .child(self.upgrade_button(cx))
4463 .child(self.create_copy_button(ERROR_MESSAGE)),
4464 )
4465 .dismiss_action(self.dismiss_error_button(cx))
4466 }
4467
4468 fn render_authentication_required_error(
4469 &self,
4470 error: SharedString,
4471 cx: &mut Context<Self>,
4472 ) -> Callout {
4473 Callout::new()
4474 .severity(Severity::Error)
4475 .title("Authentication Required")
4476 .description(error.clone())
4477 .actions_slot(
4478 h_flex()
4479 .gap_0p5()
4480 .child(self.authenticate_button(cx))
4481 .child(self.create_copy_button(error)),
4482 )
4483 .dismiss_action(self.dismiss_error_button(cx))
4484 }
4485
4486 fn render_model_request_limit_reached_error(
4487 &self,
4488 plan: cloud_llm_client::Plan,
4489 cx: &mut Context<Self>,
4490 ) -> Callout {
4491 let error_message = match plan {
4492 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
4493 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
4494 "Upgrade to Zed Pro for more prompts."
4495 }
4496 };
4497
4498 Callout::new()
4499 .severity(Severity::Error)
4500 .title("Model Prompt Limit Reached")
4501 .description(error_message)
4502 .actions_slot(
4503 h_flex()
4504 .gap_0p5()
4505 .child(self.upgrade_button(cx))
4506 .child(self.create_copy_button(error_message)),
4507 )
4508 .dismiss_action(self.dismiss_error_button(cx))
4509 }
4510
4511 fn render_tool_use_limit_reached_error(
4512 &self,
4513 window: &mut Window,
4514 cx: &mut Context<Self>,
4515 ) -> Option<Callout> {
4516 let thread = self.as_native_thread(cx)?;
4517 let supports_burn_mode = thread
4518 .read(cx)
4519 .model()
4520 .is_some_and(|model| model.supports_burn_mode());
4521
4522 let focus_handle = self.focus_handle(cx);
4523
4524 Some(
4525 Callout::new()
4526 .icon(IconName::Info)
4527 .title("Consecutive tool use limit reached.")
4528 .actions_slot(
4529 h_flex()
4530 .gap_0p5()
4531 .when(supports_burn_mode, |this| {
4532 this.child(
4533 Button::new("continue-burn-mode", "Continue with Burn Mode")
4534 .style(ButtonStyle::Filled)
4535 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4536 .layer(ElevationIndex::ModalSurface)
4537 .label_size(LabelSize::Small)
4538 .key_binding(
4539 KeyBinding::for_action_in(
4540 &ContinueWithBurnMode,
4541 &focus_handle,
4542 window,
4543 cx,
4544 )
4545 .map(|kb| kb.size(rems_from_px(10.))),
4546 )
4547 .tooltip(Tooltip::text(
4548 "Enable Burn Mode for unlimited tool use.",
4549 ))
4550 .on_click({
4551 cx.listener(move |this, _, _window, cx| {
4552 thread.update(cx, |thread, cx| {
4553 thread
4554 .set_completion_mode(CompletionMode::Burn, cx);
4555 });
4556 this.resume_chat(cx);
4557 })
4558 }),
4559 )
4560 })
4561 .child(
4562 Button::new("continue-conversation", "Continue")
4563 .layer(ElevationIndex::ModalSurface)
4564 .label_size(LabelSize::Small)
4565 .key_binding(
4566 KeyBinding::for_action_in(
4567 &ContinueThread,
4568 &focus_handle,
4569 window,
4570 cx,
4571 )
4572 .map(|kb| kb.size(rems_from_px(10.))),
4573 )
4574 .on_click(cx.listener(|this, _, _window, cx| {
4575 this.resume_chat(cx);
4576 })),
4577 ),
4578 ),
4579 )
4580 }
4581
4582 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
4583 let message = message.into();
4584
4585 IconButton::new("copy", IconName::Copy)
4586 .icon_size(IconSize::Small)
4587 .icon_color(Color::Muted)
4588 .tooltip(Tooltip::text("Copy Error Message"))
4589 .on_click(move |_, _, cx| {
4590 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
4591 })
4592 }
4593
4594 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4595 IconButton::new("dismiss", IconName::Close)
4596 .icon_size(IconSize::Small)
4597 .icon_color(Color::Muted)
4598 .tooltip(Tooltip::text("Dismiss Error"))
4599 .on_click(cx.listener({
4600 move |this, _, _, cx| {
4601 this.clear_thread_error(cx);
4602 cx.notify();
4603 }
4604 }))
4605 }
4606
4607 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4608 Button::new("authenticate", "Authenticate")
4609 .label_size(LabelSize::Small)
4610 .style(ButtonStyle::Filled)
4611 .on_click(cx.listener({
4612 move |this, _, window, cx| {
4613 let agent = this.agent.clone();
4614 let ThreadState::Ready { thread, .. } = &this.thread_state else {
4615 return;
4616 };
4617
4618 let connection = thread.read(cx).connection().clone();
4619 let err = AuthRequired {
4620 description: None,
4621 provider_id: None,
4622 };
4623 this.clear_thread_error(cx);
4624 let this = cx.weak_entity();
4625 window.defer(cx, |window, cx| {
4626 Self::handle_auth_required(this, err, agent, connection, window, cx);
4627 })
4628 }
4629 }))
4630 }
4631
4632 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4633 Button::new("upgrade", "Upgrade")
4634 .label_size(LabelSize::Small)
4635 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4636 .on_click(cx.listener({
4637 move |this, _, _, cx| {
4638 this.clear_thread_error(cx);
4639 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
4640 }
4641 }))
4642 }
4643
4644 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4645 self.thread_state = Self::initial_state(
4646 self.agent.clone(),
4647 None,
4648 self.workspace.clone(),
4649 self.project.clone(),
4650 window,
4651 cx,
4652 );
4653 cx.notify();
4654 }
4655
4656 pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
4657 let task = match entry {
4658 HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
4659 history.delete_thread(thread.id.clone(), cx)
4660 }),
4661 HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| {
4662 history.delete_text_thread(context.path.clone(), cx)
4663 }),
4664 };
4665 task.detach_and_log_err(cx);
4666 }
4667}
4668
4669impl Focusable for AcpThreadView {
4670 fn focus_handle(&self, cx: &App) -> FocusHandle {
4671 self.message_editor.focus_handle(cx)
4672 }
4673}
4674
4675impl Render for AcpThreadView {
4676 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4677 let has_messages = self.list_state.item_count() > 0;
4678 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
4679
4680 v_flex()
4681 .size_full()
4682 .key_context("AcpThread")
4683 .on_action(cx.listener(Self::open_agent_diff))
4684 .on_action(cx.listener(Self::toggle_burn_mode))
4685 .on_action(cx.listener(Self::keep_all))
4686 .on_action(cx.listener(Self::reject_all))
4687 .bg(cx.theme().colors().panel_background)
4688 .child(match &self.thread_state {
4689 ThreadState::Unauthenticated {
4690 connection,
4691 description,
4692 configuration_view,
4693 pending_auth_method,
4694 ..
4695 } => self.render_auth_required_state(
4696 connection,
4697 description.as_ref(),
4698 configuration_view.as_ref(),
4699 pending_auth_method.as_ref(),
4700 window,
4701 cx,
4702 ),
4703 ThreadState::Loading { .. } => {
4704 v_flex().flex_1().child(self.render_empty_state(window, cx))
4705 }
4706 ThreadState::LoadError(e) => v_flex()
4707 .p_2()
4708 .flex_1()
4709 .items_center()
4710 .justify_center()
4711 .child(self.render_load_error(e, cx)),
4712 ThreadState::Ready { thread, .. } => {
4713 let thread_clone = thread.clone();
4714
4715 v_flex().flex_1().map(|this| {
4716 if has_messages {
4717 this.child(
4718 list(
4719 self.list_state.clone(),
4720 cx.processor(|this, index: usize, window, cx| {
4721 let Some((entry, len)) = this.thread().and_then(|thread| {
4722 let entries = &thread.read(cx).entries();
4723 Some((entries.get(index)?, entries.len()))
4724 }) else {
4725 return Empty.into_any();
4726 };
4727 this.render_entry(index, len, entry, window, cx)
4728 }),
4729 )
4730 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
4731 .flex_grow()
4732 .into_any(),
4733 )
4734 .child(self.render_vertical_scrollbar(cx))
4735 .children(
4736 match thread_clone.read(cx).status() {
4737 ThreadStatus::Idle
4738 | ThreadStatus::WaitingForToolConfirmation => None,
4739 ThreadStatus::Generating => div()
4740 .py_2()
4741 .px(rems_from_px(22.))
4742 .child(SpinnerLabel::new().size(LabelSize::Small))
4743 .into(),
4744 },
4745 )
4746 } else {
4747 this.child(self.render_empty_state(window, cx))
4748 }
4749 })
4750 }
4751 })
4752 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
4753 // above so that the scrollbar doesn't render behind it. The current setup allows
4754 // the scrollbar to stop exactly at the activity bar start.
4755 .when(has_messages, |this| match &self.thread_state {
4756 ThreadState::Ready { thread, .. } => {
4757 this.children(self.render_activity_bar(thread, window, cx))
4758 }
4759 _ => this,
4760 })
4761 .children(self.render_thread_retry_status_callout(window, cx))
4762 .children(self.render_thread_error(window, cx))
4763 .children(
4764 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
4765 Some(usage_callout.into_any_element())
4766 } else {
4767 self.render_token_limit_callout(line_height, cx)
4768 .map(|token_limit_callout| token_limit_callout.into_any_element())
4769 },
4770 )
4771 .child(self.render_message_editor(window, cx))
4772 }
4773}
4774
4775fn default_markdown_style(
4776 buffer_font: bool,
4777 muted_text: bool,
4778 window: &Window,
4779 cx: &App,
4780) -> MarkdownStyle {
4781 let theme_settings = ThemeSettings::get_global(cx);
4782 let colors = cx.theme().colors();
4783
4784 let buffer_font_size = TextSize::Small.rems(cx);
4785
4786 let mut text_style = window.text_style();
4787 let line_height = buffer_font_size * 1.75;
4788
4789 let font_family = if buffer_font {
4790 theme_settings.buffer_font.family.clone()
4791 } else {
4792 theme_settings.ui_font.family.clone()
4793 };
4794
4795 let font_size = if buffer_font {
4796 TextSize::Small.rems(cx)
4797 } else {
4798 TextSize::Default.rems(cx)
4799 };
4800
4801 let text_color = if muted_text {
4802 colors.text_muted
4803 } else {
4804 colors.text
4805 };
4806
4807 text_style.refine(&TextStyleRefinement {
4808 font_family: Some(font_family),
4809 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
4810 font_features: Some(theme_settings.ui_font.features.clone()),
4811 font_size: Some(font_size.into()),
4812 line_height: Some(line_height.into()),
4813 color: Some(text_color),
4814 ..Default::default()
4815 });
4816
4817 MarkdownStyle {
4818 base_text_style: text_style.clone(),
4819 syntax: cx.theme().syntax().clone(),
4820 selection_background_color: colors.element_selection_background,
4821 code_block_overflow_x_scroll: true,
4822 table_overflow_x_scroll: true,
4823 heading_level_styles: Some(HeadingLevelStyles {
4824 h1: Some(TextStyleRefinement {
4825 font_size: Some(rems(1.15).into()),
4826 ..Default::default()
4827 }),
4828 h2: Some(TextStyleRefinement {
4829 font_size: Some(rems(1.1).into()),
4830 ..Default::default()
4831 }),
4832 h3: Some(TextStyleRefinement {
4833 font_size: Some(rems(1.05).into()),
4834 ..Default::default()
4835 }),
4836 h4: Some(TextStyleRefinement {
4837 font_size: Some(rems(1.).into()),
4838 ..Default::default()
4839 }),
4840 h5: Some(TextStyleRefinement {
4841 font_size: Some(rems(0.95).into()),
4842 ..Default::default()
4843 }),
4844 h6: Some(TextStyleRefinement {
4845 font_size: Some(rems(0.875).into()),
4846 ..Default::default()
4847 }),
4848 }),
4849 code_block: StyleRefinement {
4850 padding: EdgesRefinement {
4851 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4852 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4853 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4854 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
4855 },
4856 margin: EdgesRefinement {
4857 top: Some(Length::Definite(Pixels(8.).into())),
4858 left: Some(Length::Definite(Pixels(0.).into())),
4859 right: Some(Length::Definite(Pixels(0.).into())),
4860 bottom: Some(Length::Definite(Pixels(12.).into())),
4861 },
4862 border_style: Some(BorderStyle::Solid),
4863 border_widths: EdgesRefinement {
4864 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
4865 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
4866 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
4867 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
4868 },
4869 border_color: Some(colors.border_variant),
4870 background: Some(colors.editor_background.into()),
4871 text: Some(TextStyleRefinement {
4872 font_family: Some(theme_settings.buffer_font.family.clone()),
4873 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
4874 font_features: Some(theme_settings.buffer_font.features.clone()),
4875 font_size: Some(buffer_font_size.into()),
4876 ..Default::default()
4877 }),
4878 ..Default::default()
4879 },
4880 inline_code: TextStyleRefinement {
4881 font_family: Some(theme_settings.buffer_font.family.clone()),
4882 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
4883 font_features: Some(theme_settings.buffer_font.features.clone()),
4884 font_size: Some(buffer_font_size.into()),
4885 background_color: Some(colors.editor_foreground.opacity(0.08)),
4886 ..Default::default()
4887 },
4888 link: TextStyleRefinement {
4889 background_color: Some(colors.editor_foreground.opacity(0.025)),
4890 underline: Some(UnderlineStyle {
4891 color: Some(colors.text_accent.opacity(0.5)),
4892 thickness: px(1.),
4893 ..Default::default()
4894 }),
4895 ..Default::default()
4896 },
4897 ..Default::default()
4898 }
4899}
4900
4901fn plan_label_markdown_style(
4902 status: &acp::PlanEntryStatus,
4903 window: &Window,
4904 cx: &App,
4905) -> MarkdownStyle {
4906 let default_md_style = default_markdown_style(false, false, window, cx);
4907
4908 MarkdownStyle {
4909 base_text_style: TextStyle {
4910 color: cx.theme().colors().text_muted,
4911 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
4912 Some(gpui::StrikethroughStyle {
4913 thickness: px(1.),
4914 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
4915 })
4916 } else {
4917 None
4918 },
4919 ..default_md_style.base_text_style
4920 },
4921 ..default_md_style
4922 }
4923}
4924
4925fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
4926 let default_md_style = default_markdown_style(true, false, window, cx);
4927
4928 MarkdownStyle {
4929 base_text_style: TextStyle {
4930 ..default_md_style.base_text_style
4931 },
4932 selection_background_color: cx.theme().colors().element_selection_background,
4933 ..Default::default()
4934 }
4935}
4936
4937#[cfg(test)]
4938pub(crate) mod tests {
4939 use acp_thread::StubAgentConnection;
4940 use agent_client_protocol::SessionId;
4941 use assistant_context::ContextStore;
4942 use editor::EditorSettings;
4943 use fs::FakeFs;
4944 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
4945 use project::Project;
4946 use serde_json::json;
4947 use settings::SettingsStore;
4948 use std::any::Any;
4949 use std::path::Path;
4950 use workspace::Item;
4951
4952 use super::*;
4953
4954 #[gpui::test]
4955 async fn test_drop(cx: &mut TestAppContext) {
4956 init_test(cx);
4957
4958 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
4959 let weak_view = thread_view.downgrade();
4960 drop(thread_view);
4961 assert!(!weak_view.is_upgradable());
4962 }
4963
4964 #[gpui::test]
4965 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
4966 init_test(cx);
4967
4968 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
4969
4970 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4971 message_editor.update_in(cx, |editor, window, cx| {
4972 editor.set_text("Hello", window, cx);
4973 });
4974
4975 cx.deactivate_window();
4976
4977 thread_view.update_in(cx, |thread_view, window, cx| {
4978 thread_view.send(window, cx);
4979 });
4980
4981 cx.run_until_parked();
4982
4983 assert!(
4984 cx.windows()
4985 .iter()
4986 .any(|window| window.downcast::<AgentNotification>().is_some())
4987 );
4988 }
4989
4990 #[gpui::test]
4991 async fn test_notification_for_error(cx: &mut TestAppContext) {
4992 init_test(cx);
4993
4994 let (thread_view, cx) =
4995 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
4996
4997 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4998 message_editor.update_in(cx, |editor, window, cx| {
4999 editor.set_text("Hello", window, cx);
5000 });
5001
5002 cx.deactivate_window();
5003
5004 thread_view.update_in(cx, |thread_view, window, cx| {
5005 thread_view.send(window, cx);
5006 });
5007
5008 cx.run_until_parked();
5009
5010 assert!(
5011 cx.windows()
5012 .iter()
5013 .any(|window| window.downcast::<AgentNotification>().is_some())
5014 );
5015 }
5016
5017 #[gpui::test]
5018 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
5019 init_test(cx);
5020
5021 let tool_call_id = acp::ToolCallId("1".into());
5022 let tool_call = acp::ToolCall {
5023 id: tool_call_id.clone(),
5024 title: "Label".into(),
5025 kind: acp::ToolKind::Edit,
5026 status: acp::ToolCallStatus::Pending,
5027 content: vec!["hi".into()],
5028 locations: vec![],
5029 raw_input: None,
5030 raw_output: None,
5031 };
5032 let connection =
5033 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5034 tool_call_id,
5035 vec![acp::PermissionOption {
5036 id: acp::PermissionOptionId("1".into()),
5037 name: "Allow".into(),
5038 kind: acp::PermissionOptionKind::AllowOnce,
5039 }],
5040 )]));
5041
5042 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5043
5044 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5045
5046 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5047 message_editor.update_in(cx, |editor, window, cx| {
5048 editor.set_text("Hello", window, cx);
5049 });
5050
5051 cx.deactivate_window();
5052
5053 thread_view.update_in(cx, |thread_view, window, cx| {
5054 thread_view.send(window, cx);
5055 });
5056
5057 cx.run_until_parked();
5058
5059 assert!(
5060 cx.windows()
5061 .iter()
5062 .any(|window| window.downcast::<AgentNotification>().is_some())
5063 );
5064 }
5065
5066 async fn setup_thread_view(
5067 agent: impl AgentServer + 'static,
5068 cx: &mut TestAppContext,
5069 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
5070 let fs = FakeFs::new(cx.executor());
5071 let project = Project::test(fs, [], cx).await;
5072 let (workspace, cx) =
5073 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5074
5075 let context_store =
5076 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5077 let history_store =
5078 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5079
5080 let thread_view = cx.update(|window, cx| {
5081 cx.new(|cx| {
5082 AcpThreadView::new(
5083 Rc::new(agent),
5084 None,
5085 None,
5086 workspace.downgrade(),
5087 project,
5088 history_store,
5089 None,
5090 window,
5091 cx,
5092 )
5093 })
5094 });
5095 cx.run_until_parked();
5096 (thread_view, cx)
5097 }
5098
5099 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
5100 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
5101
5102 workspace
5103 .update_in(cx, |workspace, window, cx| {
5104 workspace.add_item_to_active_pane(
5105 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
5106 None,
5107 true,
5108 window,
5109 cx,
5110 );
5111 })
5112 .unwrap();
5113 }
5114
5115 struct ThreadViewItem(Entity<AcpThreadView>);
5116
5117 impl Item for ThreadViewItem {
5118 type Event = ();
5119
5120 fn include_in_nav_history() -> bool {
5121 false
5122 }
5123
5124 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
5125 "Test".into()
5126 }
5127 }
5128
5129 impl EventEmitter<()> for ThreadViewItem {}
5130
5131 impl Focusable for ThreadViewItem {
5132 fn focus_handle(&self, cx: &App) -> FocusHandle {
5133 self.0.read(cx).focus_handle(cx)
5134 }
5135 }
5136
5137 impl Render for ThreadViewItem {
5138 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5139 self.0.clone().into_any_element()
5140 }
5141 }
5142
5143 struct StubAgentServer<C> {
5144 connection: C,
5145 }
5146
5147 impl<C> StubAgentServer<C> {
5148 fn new(connection: C) -> Self {
5149 Self { connection }
5150 }
5151 }
5152
5153 impl StubAgentServer<StubAgentConnection> {
5154 fn default_response() -> Self {
5155 let conn = StubAgentConnection::new();
5156 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5157 content: "Default response".into(),
5158 }]);
5159 Self::new(conn)
5160 }
5161 }
5162
5163 impl<C> AgentServer for StubAgentServer<C>
5164 where
5165 C: 'static + AgentConnection + Send + Clone,
5166 {
5167 fn logo(&self) -> ui::IconName {
5168 ui::IconName::Ai
5169 }
5170
5171 fn name(&self) -> &'static str {
5172 "Test"
5173 }
5174
5175 fn empty_state_headline(&self) -> &'static str {
5176 "Test"
5177 }
5178
5179 fn empty_state_message(&self) -> &'static str {
5180 "Test"
5181 }
5182
5183 fn connect(
5184 &self,
5185 _root_dir: &Path,
5186 _project: &Entity<Project>,
5187 _cx: &mut App,
5188 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5189 Task::ready(Ok(Rc::new(self.connection.clone())))
5190 }
5191
5192 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5193 self
5194 }
5195 }
5196
5197 #[derive(Clone)]
5198 struct SaboteurAgentConnection;
5199
5200 impl AgentConnection for SaboteurAgentConnection {
5201 fn new_thread(
5202 self: Rc<Self>,
5203 project: Entity<Project>,
5204 _cwd: &Path,
5205 cx: &mut gpui::App,
5206 ) -> Task<gpui::Result<Entity<AcpThread>>> {
5207 Task::ready(Ok(cx.new(|cx| {
5208 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5209 AcpThread::new(
5210 "SaboteurAgentConnection",
5211 self,
5212 project,
5213 action_log,
5214 SessionId("test".into()),
5215 )
5216 })))
5217 }
5218
5219 fn auth_methods(&self) -> &[acp::AuthMethod] {
5220 &[]
5221 }
5222
5223 fn prompt_capabilities(&self) -> acp::PromptCapabilities {
5224 acp::PromptCapabilities {
5225 image: true,
5226 audio: true,
5227 embedded_context: true,
5228 }
5229 }
5230
5231 fn authenticate(
5232 &self,
5233 _method_id: acp::AuthMethodId,
5234 _cx: &mut App,
5235 ) -> Task<gpui::Result<()>> {
5236 unimplemented!()
5237 }
5238
5239 fn prompt(
5240 &self,
5241 _id: Option<acp_thread::UserMessageId>,
5242 _params: acp::PromptRequest,
5243 _cx: &mut App,
5244 ) -> Task<gpui::Result<acp::PromptResponse>> {
5245 Task::ready(Err(anyhow::anyhow!("Error prompting")))
5246 }
5247
5248 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
5249 unimplemented!()
5250 }
5251
5252 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5253 self
5254 }
5255 }
5256
5257 pub(crate) fn init_test(cx: &mut TestAppContext) {
5258 cx.update(|cx| {
5259 let settings_store = SettingsStore::test(cx);
5260 cx.set_global(settings_store);
5261 language::init(cx);
5262 Project::init_settings(cx);
5263 AgentSettings::register(cx);
5264 workspace::init_settings(cx);
5265 ThemeSettings::register(cx);
5266 release_channel::init(SemanticVersion::default(), cx);
5267 EditorSettings::register(cx);
5268 prompt_store::init(cx)
5269 });
5270 }
5271
5272 #[gpui::test]
5273 async fn test_rewind_views(cx: &mut TestAppContext) {
5274 init_test(cx);
5275
5276 let fs = FakeFs::new(cx.executor());
5277 fs.insert_tree(
5278 "/project",
5279 json!({
5280 "test1.txt": "old content 1",
5281 "test2.txt": "old content 2"
5282 }),
5283 )
5284 .await;
5285 let project = Project::test(fs, [Path::new("/project")], cx).await;
5286 let (workspace, cx) =
5287 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5288
5289 let context_store =
5290 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5291 let history_store =
5292 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5293
5294 let connection = Rc::new(StubAgentConnection::new());
5295 let thread_view = cx.update(|window, cx| {
5296 cx.new(|cx| {
5297 AcpThreadView::new(
5298 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
5299 None,
5300 None,
5301 workspace.downgrade(),
5302 project.clone(),
5303 history_store.clone(),
5304 None,
5305 window,
5306 cx,
5307 )
5308 })
5309 });
5310
5311 cx.run_until_parked();
5312
5313 let thread = thread_view
5314 .read_with(cx, |view, _| view.thread().cloned())
5315 .unwrap();
5316
5317 // First user message
5318 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5319 id: acp::ToolCallId("tool1".into()),
5320 title: "Edit file 1".into(),
5321 kind: acp::ToolKind::Edit,
5322 status: acp::ToolCallStatus::Completed,
5323 content: vec![acp::ToolCallContent::Diff {
5324 diff: acp::Diff {
5325 path: "/project/test1.txt".into(),
5326 old_text: Some("old content 1".into()),
5327 new_text: "new content 1".into(),
5328 },
5329 }],
5330 locations: vec![],
5331 raw_input: None,
5332 raw_output: None,
5333 })]);
5334
5335 thread
5336 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
5337 .await
5338 .unwrap();
5339 cx.run_until_parked();
5340
5341 thread.read_with(cx, |thread, _| {
5342 assert_eq!(thread.entries().len(), 2);
5343 });
5344
5345 thread_view.read_with(cx, |view, cx| {
5346 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5347 assert!(
5348 entry_view_state
5349 .entry(0)
5350 .unwrap()
5351 .message_editor()
5352 .is_some()
5353 );
5354 assert!(entry_view_state.entry(1).unwrap().has_content());
5355 });
5356 });
5357
5358 // Second user message
5359 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5360 id: acp::ToolCallId("tool2".into()),
5361 title: "Edit file 2".into(),
5362 kind: acp::ToolKind::Edit,
5363 status: acp::ToolCallStatus::Completed,
5364 content: vec![acp::ToolCallContent::Diff {
5365 diff: acp::Diff {
5366 path: "/project/test2.txt".into(),
5367 old_text: Some("old content 2".into()),
5368 new_text: "new content 2".into(),
5369 },
5370 }],
5371 locations: vec![],
5372 raw_input: None,
5373 raw_output: None,
5374 })]);
5375
5376 thread
5377 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
5378 .await
5379 .unwrap();
5380 cx.run_until_parked();
5381
5382 let second_user_message_id = thread.read_with(cx, |thread, _| {
5383 assert_eq!(thread.entries().len(), 4);
5384 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
5385 panic!();
5386 };
5387 user_message.id.clone().unwrap()
5388 });
5389
5390 thread_view.read_with(cx, |view, cx| {
5391 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5392 assert!(
5393 entry_view_state
5394 .entry(0)
5395 .unwrap()
5396 .message_editor()
5397 .is_some()
5398 );
5399 assert!(entry_view_state.entry(1).unwrap().has_content());
5400 assert!(
5401 entry_view_state
5402 .entry(2)
5403 .unwrap()
5404 .message_editor()
5405 .is_some()
5406 );
5407 assert!(entry_view_state.entry(3).unwrap().has_content());
5408 });
5409 });
5410
5411 // Rewind to first message
5412 thread
5413 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
5414 .await
5415 .unwrap();
5416
5417 cx.run_until_parked();
5418
5419 thread.read_with(cx, |thread, _| {
5420 assert_eq!(thread.entries().len(), 2);
5421 });
5422
5423 thread_view.read_with(cx, |view, cx| {
5424 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5425 assert!(
5426 entry_view_state
5427 .entry(0)
5428 .unwrap()
5429 .message_editor()
5430 .is_some()
5431 );
5432 assert!(entry_view_state.entry(1).unwrap().has_content());
5433
5434 // Old views should be dropped
5435 assert!(entry_view_state.entry(2).is_none());
5436 assert!(entry_view_state.entry(3).is_none());
5437 });
5438 });
5439 }
5440
5441 #[gpui::test]
5442 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5443 init_test(cx);
5444
5445 let connection = StubAgentConnection::new();
5446
5447 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5448 content: acp::ContentBlock::Text(acp::TextContent {
5449 text: "Response".into(),
5450 annotations: None,
5451 }),
5452 }]);
5453
5454 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5455 add_to_workspace(thread_view.clone(), cx);
5456
5457 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5458 message_editor.update_in(cx, |editor, window, cx| {
5459 editor.set_text("Original message to edit", window, cx);
5460 });
5461 thread_view.update_in(cx, |thread_view, window, cx| {
5462 thread_view.send(window, cx);
5463 });
5464
5465 cx.run_until_parked();
5466
5467 let user_message_editor = thread_view.read_with(cx, |view, cx| {
5468 assert_eq!(view.editing_message, None);
5469
5470 view.entry_view_state
5471 .read(cx)
5472 .entry(0)
5473 .unwrap()
5474 .message_editor()
5475 .unwrap()
5476 .clone()
5477 });
5478
5479 // Focus
5480 cx.focus(&user_message_editor);
5481 thread_view.read_with(cx, |view, _cx| {
5482 assert_eq!(view.editing_message, Some(0));
5483 });
5484
5485 // Edit
5486 user_message_editor.update_in(cx, |editor, window, cx| {
5487 editor.set_text("Edited message content", window, cx);
5488 });
5489
5490 // Cancel
5491 user_message_editor.update_in(cx, |_editor, window, cx| {
5492 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
5493 });
5494
5495 thread_view.read_with(cx, |view, _cx| {
5496 assert_eq!(view.editing_message, None);
5497 });
5498
5499 user_message_editor.read_with(cx, |editor, cx| {
5500 assert_eq!(editor.text(cx), "Original message to edit");
5501 });
5502 }
5503
5504 #[gpui::test]
5505 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
5506 init_test(cx);
5507
5508 let connection = StubAgentConnection::new();
5509
5510 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5511 add_to_workspace(thread_view.clone(), cx);
5512
5513 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5514 let mut events = cx.events(&message_editor);
5515 message_editor.update_in(cx, |editor, window, cx| {
5516 editor.set_text("", window, cx);
5517 });
5518
5519 message_editor.update_in(cx, |_editor, window, cx| {
5520 window.dispatch_action(Box::new(Chat), cx);
5521 });
5522 cx.run_until_parked();
5523 // We shouldn't have received any messages
5524 assert!(matches!(
5525 events.try_next(),
5526 Err(futures::channel::mpsc::TryRecvError { .. })
5527 ));
5528 }
5529
5530 #[gpui::test]
5531 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
5532 init_test(cx);
5533
5534 let connection = StubAgentConnection::new();
5535
5536 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5537 content: acp::ContentBlock::Text(acp::TextContent {
5538 text: "Response".into(),
5539 annotations: None,
5540 }),
5541 }]);
5542
5543 let (thread_view, cx) =
5544 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5545 add_to_workspace(thread_view.clone(), cx);
5546
5547 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5548 message_editor.update_in(cx, |editor, window, cx| {
5549 editor.set_text("Original message to edit", window, cx);
5550 });
5551 thread_view.update_in(cx, |thread_view, window, cx| {
5552 thread_view.send(window, cx);
5553 });
5554
5555 cx.run_until_parked();
5556
5557 let user_message_editor = thread_view.read_with(cx, |view, cx| {
5558 assert_eq!(view.editing_message, None);
5559 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
5560
5561 view.entry_view_state
5562 .read(cx)
5563 .entry(0)
5564 .unwrap()
5565 .message_editor()
5566 .unwrap()
5567 .clone()
5568 });
5569
5570 // Focus
5571 cx.focus(&user_message_editor);
5572
5573 // Edit
5574 user_message_editor.update_in(cx, |editor, window, cx| {
5575 editor.set_text("Edited message content", window, cx);
5576 });
5577
5578 // Send
5579 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5580 content: acp::ContentBlock::Text(acp::TextContent {
5581 text: "New Response".into(),
5582 annotations: None,
5583 }),
5584 }]);
5585
5586 user_message_editor.update_in(cx, |_editor, window, cx| {
5587 window.dispatch_action(Box::new(Chat), cx);
5588 });
5589
5590 cx.run_until_parked();
5591
5592 thread_view.read_with(cx, |view, cx| {
5593 assert_eq!(view.editing_message, None);
5594
5595 let entries = view.thread().unwrap().read(cx).entries();
5596 assert_eq!(entries.len(), 2);
5597 assert_eq!(
5598 entries[0].to_markdown(cx),
5599 "## User\n\nEdited message content\n\n"
5600 );
5601 assert_eq!(
5602 entries[1].to_markdown(cx),
5603 "## Assistant\n\nNew Response\n\n"
5604 );
5605
5606 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
5607 assert!(!state.entry(1).unwrap().has_content());
5608 state.entry(0).unwrap().message_editor().unwrap().clone()
5609 });
5610
5611 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
5612 })
5613 }
5614
5615 #[gpui::test]
5616 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
5617 init_test(cx);
5618
5619 let connection = StubAgentConnection::new();
5620
5621 let (thread_view, cx) =
5622 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5623 add_to_workspace(thread_view.clone(), cx);
5624
5625 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5626 message_editor.update_in(cx, |editor, window, cx| {
5627 editor.set_text("Original message to edit", window, cx);
5628 });
5629 thread_view.update_in(cx, |thread_view, window, cx| {
5630 thread_view.send(window, cx);
5631 });
5632
5633 cx.run_until_parked();
5634
5635 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
5636 let thread = view.thread().unwrap().read(cx);
5637 assert_eq!(thread.entries().len(), 1);
5638
5639 let editor = view
5640 .entry_view_state
5641 .read(cx)
5642 .entry(0)
5643 .unwrap()
5644 .message_editor()
5645 .unwrap()
5646 .clone();
5647
5648 (editor, thread.session_id().clone())
5649 });
5650
5651 // Focus
5652 cx.focus(&user_message_editor);
5653
5654 thread_view.read_with(cx, |view, _cx| {
5655 assert_eq!(view.editing_message, Some(0));
5656 });
5657
5658 // Edit
5659 user_message_editor.update_in(cx, |editor, window, cx| {
5660 editor.set_text("Edited message content", window, cx);
5661 });
5662
5663 thread_view.read_with(cx, |view, _cx| {
5664 assert_eq!(view.editing_message, Some(0));
5665 });
5666
5667 // Finish streaming response
5668 cx.update(|_, cx| {
5669 connection.send_update(
5670 session_id.clone(),
5671 acp::SessionUpdate::AgentMessageChunk {
5672 content: acp::ContentBlock::Text(acp::TextContent {
5673 text: "Response".into(),
5674 annotations: None,
5675 }),
5676 },
5677 cx,
5678 );
5679 connection.end_turn(session_id, acp::StopReason::EndTurn);
5680 });
5681
5682 thread_view.read_with(cx, |view, _cx| {
5683 assert_eq!(view.editing_message, Some(0));
5684 });
5685
5686 cx.run_until_parked();
5687
5688 // Should still be editing
5689 cx.update(|window, cx| {
5690 assert!(user_message_editor.focus_handle(cx).is_focused(window));
5691 assert_eq!(thread_view.read(cx).editing_message, Some(0));
5692 assert_eq!(
5693 user_message_editor.read(cx).text(cx),
5694 "Edited message content"
5695 );
5696 });
5697 }
5698
5699 #[gpui::test]
5700 async fn test_interrupt(cx: &mut TestAppContext) {
5701 init_test(cx);
5702
5703 let connection = StubAgentConnection::new();
5704
5705 let (thread_view, cx) =
5706 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5707 add_to_workspace(thread_view.clone(), cx);
5708
5709 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5710 message_editor.update_in(cx, |editor, window, cx| {
5711 editor.set_text("Message 1", window, cx);
5712 });
5713 thread_view.update_in(cx, |thread_view, window, cx| {
5714 thread_view.send(window, cx);
5715 });
5716
5717 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
5718 let thread = view.thread().unwrap();
5719
5720 (thread.clone(), thread.read(cx).session_id().clone())
5721 });
5722
5723 cx.run_until_parked();
5724
5725 cx.update(|_, cx| {
5726 connection.send_update(
5727 session_id.clone(),
5728 acp::SessionUpdate::AgentMessageChunk {
5729 content: "Message 1 resp".into(),
5730 },
5731 cx,
5732 );
5733 });
5734
5735 cx.run_until_parked();
5736
5737 thread.read_with(cx, |thread, cx| {
5738 assert_eq!(
5739 thread.to_markdown(cx),
5740 indoc::indoc! {"
5741 ## User
5742
5743 Message 1
5744
5745 ## Assistant
5746
5747 Message 1 resp
5748
5749 "}
5750 )
5751 });
5752
5753 message_editor.update_in(cx, |editor, window, cx| {
5754 editor.set_text("Message 2", window, cx);
5755 });
5756 thread_view.update_in(cx, |thread_view, window, cx| {
5757 thread_view.send(window, cx);
5758 });
5759
5760 cx.update(|_, cx| {
5761 // Simulate a response sent after beginning to cancel
5762 connection.send_update(
5763 session_id.clone(),
5764 acp::SessionUpdate::AgentMessageChunk {
5765 content: "onse".into(),
5766 },
5767 cx,
5768 );
5769 });
5770
5771 cx.run_until_parked();
5772
5773 // Last Message 1 response should appear before Message 2
5774 thread.read_with(cx, |thread, cx| {
5775 assert_eq!(
5776 thread.to_markdown(cx),
5777 indoc::indoc! {"
5778 ## User
5779
5780 Message 1
5781
5782 ## Assistant
5783
5784 Message 1 response
5785
5786 ## User
5787
5788 Message 2
5789
5790 "}
5791 )
5792 });
5793
5794 cx.update(|_, cx| {
5795 connection.send_update(
5796 session_id.clone(),
5797 acp::SessionUpdate::AgentMessageChunk {
5798 content: "Message 2 response".into(),
5799 },
5800 cx,
5801 );
5802 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5803 });
5804
5805 cx.run_until_parked();
5806
5807 thread.read_with(cx, |thread, cx| {
5808 assert_eq!(
5809 thread.to_markdown(cx),
5810 indoc::indoc! {"
5811 ## User
5812
5813 Message 1
5814
5815 ## Assistant
5816
5817 Message 1 response
5818
5819 ## User
5820
5821 Message 2
5822
5823 ## Assistant
5824
5825 Message 2 response
5826
5827 "}
5828 )
5829 });
5830 }
5831}