1use acp_thread::{
2 AcpThread, AcpThreadEvent, AcpThreadMetadata, AgentThreadEntry, AssistantMessage,
3 AssistantMessageChunk, LoadError, MentionUri, ThreadStatus, ToolCall, ToolCallContent,
4 ToolCallStatus, UserMessageId,
5};
6use acp_thread::{AgentConnection, Plan};
7use action_log::ActionLog;
8use agent::{TextThreadStore, ThreadStore};
9use agent_client_protocol::{self as acp};
10use agent_servers::AgentServer;
11use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
12use anyhow::bail;
13use audio::{Audio, Sound};
14use buffer_diff::BufferDiff;
15use client::zed_urls;
16use collections::{HashMap, HashSet};
17use editor::scroll::Autoscroll;
18use editor::{Editor, EditorMode, MultiBuffer, PathKey, SelectionEffects};
19use file_icons::FileIcons;
20use fs::Fs;
21use gpui::{
22 Action, Animation, AnimationExt, App, BorderStyle, ClickEvent, ClipboardItem, EdgesRefinement,
23 Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, MouseButton,
24 PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, Task, TextStyle,
25 TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
26 linear_color_stop, linear_gradient, list, percentage, point, prelude::*, pulsating_between,
27};
28use language::Buffer;
29use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
30use project::Project;
31use prompt_store::PromptId;
32use rope::Point;
33use settings::{Settings as _, SettingsStore};
34use std::sync::Arc;
35use std::{collections::BTreeMap, process::ExitStatus, rc::Rc, time::Duration};
36use text::Anchor;
37use theme::ThemeSettings;
38use ui::{
39 Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
40 Scrollbar, ScrollbarState, Tooltip, prelude::*,
41};
42use util::{ResultExt, size::format_file_size, time::duration_alt_display};
43use workspace::{CollaboratorId, Workspace};
44use zed_actions::agent::{Chat, ToggleModelSelector};
45use zed_actions::assistant::OpenRulesLibrary;
46
47use super::entry_view_state::EntryViewState;
48use crate::acp::AcpModelSelectorPopover;
49use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
50use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
51use crate::agent_diff::AgentDiff;
52use crate::profile_selector::{ProfileProvider, ProfileSelector};
53use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip};
54use crate::{
55 AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
56 KeepAll, OpenAgentDiff, RejectAll, ToggleBurnMode, ToggleProfileSelector,
57};
58
59const RESPONSE_PADDING_X: Pixels = px(19.);
60pub const MIN_EDITOR_LINES: usize = 4;
61pub const MAX_EDITOR_LINES: usize = 8;
62
63enum ThreadError {
64 PaymentRequired,
65 ModelRequestLimitReached(cloud_llm_client::Plan),
66 ToolUseLimitReached,
67 Other(SharedString),
68}
69
70impl ThreadError {
71 fn from_err(error: anyhow::Error) -> Self {
72 if error.is::<language_model::PaymentRequiredError>() {
73 Self::PaymentRequired
74 } else if error.is::<language_model::ToolUseLimitReachedError>() {
75 Self::ToolUseLimitReached
76 } else if let Some(error) =
77 error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
78 {
79 Self::ModelRequestLimitReached(error.plan)
80 } else {
81 Self::Other(error.to_string().into())
82 }
83 }
84}
85
86impl ProfileProvider for Entity<agent2::Thread> {
87 fn profile_id(&self, cx: &App) -> AgentProfileId {
88 self.read(cx).profile().clone()
89 }
90
91 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
92 self.update(cx, |thread, _cx| {
93 thread.set_profile(profile_id);
94 });
95 }
96
97 fn profiles_supported(&self, cx: &App) -> bool {
98 self.read(cx)
99 .model()
100 .map_or(false, |model| model.supports_tools())
101 }
102}
103
104pub struct AcpThreadView {
105 agent: Rc<dyn AgentServer>,
106 workspace: WeakEntity<Workspace>,
107 project: Entity<Project>,
108 thread_state: ThreadState,
109 entry_view_state: Entity<EntryViewState>,
110 message_editor: Entity<MessageEditor>,
111 model_selector: Option<Entity<AcpModelSelectorPopover>>,
112 profile_selector: Option<Entity<ProfileSelector>>,
113 notifications: Vec<WindowHandle<AgentNotification>>,
114 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
115 thread_error: Option<ThreadError>,
116 list_state: ListState,
117 scrollbar_state: ScrollbarState,
118 auth_task: Option<Task<()>>,
119 expanded_tool_calls: HashSet<acp::ToolCallId>,
120 expanded_thinking_blocks: HashSet<(usize, usize)>,
121 edits_expanded: bool,
122 plan_expanded: bool,
123 editor_expanded: bool,
124 terminal_expanded: bool,
125 editing_message: Option<usize>,
126 _cancel_task: Option<Task<()>>,
127 _subscriptions: [Subscription; 3],
128}
129
130enum ThreadState {
131 Loading {
132 _task: Task<()>,
133 },
134 Ready {
135 thread: Entity<AcpThread>,
136 _subscription: [Subscription; 2],
137 },
138 LoadError(LoadError),
139 Unauthenticated {
140 connection: Rc<dyn AgentConnection>,
141 },
142 ServerExited {
143 status: ExitStatus,
144 },
145}
146
147impl AcpThreadView {
148 pub fn new(
149 agent: Rc<dyn AgentServer>,
150 workspace: WeakEntity<Workspace>,
151 project: Entity<Project>,
152 thread_store: Entity<ThreadStore>,
153 text_thread_store: Entity<TextThreadStore>,
154 restore_thread: Option<AcpThreadMetadata>,
155 window: &mut Window,
156 cx: &mut Context<Self>,
157 ) -> Self {
158 let message_editor = cx.new(|cx| {
159 MessageEditor::new(
160 workspace.clone(),
161 project.clone(),
162 thread_store.clone(),
163 text_thread_store.clone(),
164 "Message the agent - @ to include context",
165 editor::EditorMode::AutoHeight {
166 min_lines: MIN_EDITOR_LINES,
167 max_lines: Some(MAX_EDITOR_LINES),
168 },
169 window,
170 cx,
171 )
172 });
173
174 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
175
176 let entry_view_state = cx.new(|_| {
177 EntryViewState::new(
178 workspace.clone(),
179 project.clone(),
180 thread_store.clone(),
181 text_thread_store.clone(),
182 )
183 });
184
185 let subscriptions = [
186 cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
187 cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
188 cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
189 ];
190
191 Self {
192 agent: agent.clone(),
193 workspace: workspace.clone(),
194 project: project.clone(),
195 entry_view_state,
196 thread_state: Self::initial_state(
197 agent,
198 restore_thread,
199 workspace,
200 project,
201 window,
202 cx,
203 ),
204 message_editor,
205 model_selector: None,
206 profile_selector: None,
207 notifications: Vec::new(),
208 notification_subscriptions: HashMap::default(),
209 list_state: list_state.clone(),
210 scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
211 thread_error: None,
212 auth_task: None,
213 expanded_tool_calls: HashSet::default(),
214 expanded_thinking_blocks: HashSet::default(),
215 editing_message: None,
216 edits_expanded: false,
217 plan_expanded: false,
218 editor_expanded: false,
219 terminal_expanded: true,
220 _subscriptions: subscriptions,
221 _cancel_task: None,
222 }
223 }
224
225 fn initial_state(
226 agent: Rc<dyn AgentServer>,
227 restore_thread: Option<AcpThreadMetadata>,
228 workspace: WeakEntity<Workspace>,
229 project: Entity<Project>,
230 window: &mut Window,
231 cx: &mut Context<Self>,
232 ) -> ThreadState {
233 let root_dir = project
234 .read(cx)
235 .visible_worktrees(cx)
236 .next()
237 .map(|worktree| worktree.read(cx).abs_path())
238 .unwrap_or_else(|| paths::home_dir().as_path().into());
239
240 let connect_task = agent.connect(&root_dir, &project, cx);
241 let load_task = cx.spawn_in(window, async move |this, cx| {
242 let connection = match connect_task.await {
243 Ok(connection) => connection,
244 Err(err) => {
245 this.update(cx, |this, cx| {
246 this.handle_load_error(err, cx);
247 cx.notify();
248 })
249 .log_err();
250 return;
251 }
252 };
253
254 // this.update_in(cx, |_this, _window, cx| {
255 // let status = connection.exit_status(cx);
256 // cx.spawn(async move |this, cx| {
257 // let status = status.await.ok();
258 // this.update(cx, |this, cx| {
259 // this.thread_state = ThreadState::ServerExited { status };
260 // cx.notify();
261 // })
262 // .ok();
263 // })
264 // .detach();
265 // })
266 // .ok();
267 //
268 let task = cx.update(|_, cx| {
269 if let Some(restore_thread) = restore_thread {
270 connection.clone().load_thread(
271 project.clone(),
272 &root_dir,
273 restore_thread.id,
274 cx,
275 )
276 } else {
277 connection
278 .clone()
279 .new_thread(project.clone(), &root_dir, cx)
280 }
281 });
282
283 let Ok(task) = task else {
284 return;
285 };
286
287 let result = match task.await {
288 Err(e) => {
289 let mut cx = cx.clone();
290 if e.is::<acp_thread::AuthRequired>() {
291 this.update(&mut cx, |this, cx| {
292 this.thread_state = ThreadState::Unauthenticated { connection };
293 cx.notify();
294 })
295 .ok();
296 return;
297 } else {
298 Err(e)
299 }
300 }
301 Ok(thread) => Ok(thread),
302 };
303
304 this.update_in(cx, |this, window, cx| {
305 match result {
306 Ok(thread) => {
307 let thread_subscription =
308 cx.subscribe_in(&thread, window, Self::handle_thread_event);
309
310 let action_log = thread.read(cx).action_log().clone();
311 let action_log_subscription =
312 cx.observe(&action_log, |_, _, cx| cx.notify());
313
314 let count = thread.read(cx).entries().len();
315 this.list_state.splice(0..0, count);
316 this.entry_view_state.update(cx, |view_state, cx| {
317 for ix in 0..count {
318 view_state.sync_entry(ix, &thread, window, cx);
319 }
320 });
321
322 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
323
324 this.model_selector =
325 thread
326 .read(cx)
327 .connection()
328 .model_selector()
329 .map(|selector| {
330 cx.new(|cx| {
331 AcpModelSelectorPopover::new(
332 thread.read(cx).session_id().clone(),
333 selector,
334 PopoverMenuHandle::default(),
335 this.focus_handle(cx),
336 window,
337 cx,
338 )
339 })
340 });
341
342 this.thread_state = ThreadState::Ready {
343 thread,
344 _subscription: [thread_subscription, action_log_subscription],
345 };
346
347 this.profile_selector = this.as_native_thread(cx).map(|thread| {
348 cx.new(|cx| {
349 ProfileSelector::new(
350 <dyn Fs>::global(cx),
351 Arc::new(thread.clone()),
352 this.focus_handle(cx),
353 cx,
354 )
355 })
356 });
357
358 cx.notify();
359 }
360 Err(err) => {
361 this.handle_load_error(err, cx);
362 }
363 };
364 })
365 .log_err();
366 });
367
368 ThreadState::Loading { _task: load_task }
369 }
370
371 fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context<Self>) {
372 if let Some(load_err) = err.downcast_ref::<LoadError>() {
373 self.thread_state = ThreadState::LoadError(load_err.clone());
374 } else {
375 self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
376 }
377 cx.notify();
378 }
379
380 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
381 match &self.thread_state {
382 ThreadState::Ready { thread, .. } => Some(thread),
383 ThreadState::Unauthenticated { .. }
384 | ThreadState::Loading { .. }
385 | ThreadState::LoadError(..)
386 | ThreadState::ServerExited { .. } => None,
387 }
388 }
389
390 pub fn title(&self, cx: &App) -> SharedString {
391 match &self.thread_state {
392 ThreadState::Ready { thread, .. } => thread.read(cx).title(),
393 ThreadState::Loading { .. } => "Loading…".into(),
394 ThreadState::LoadError(_) => "Failed to load".into(),
395 ThreadState::Unauthenticated { .. } => "Not authenticated".into(),
396 ThreadState::ServerExited { .. } => "Server exited unexpectedly".into(),
397 }
398 }
399
400 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
401 self.thread_error.take();
402
403 if let Some(thread) = self.thread() {
404 self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
405 }
406 }
407
408 pub fn expand_message_editor(
409 &mut self,
410 _: &ExpandMessageEditor,
411 _window: &mut Window,
412 cx: &mut Context<Self>,
413 ) {
414 self.set_editor_is_expanded(!self.editor_expanded, cx);
415 cx.notify();
416 }
417
418 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
419 self.editor_expanded = is_expanded;
420 self.message_editor.update(cx, |editor, cx| {
421 if is_expanded {
422 editor.set_mode(
423 EditorMode::Full {
424 scale_ui_elements_with_buffer_font_size: false,
425 show_active_line_background: false,
426 sized_by_content: false,
427 },
428 cx,
429 )
430 } else {
431 editor.set_mode(
432 EditorMode::AutoHeight {
433 min_lines: MIN_EDITOR_LINES,
434 max_lines: Some(MAX_EDITOR_LINES),
435 },
436 cx,
437 )
438 }
439 });
440 cx.notify();
441 }
442
443 pub fn handle_message_editor_event(
444 &mut self,
445 _: &Entity<MessageEditor>,
446 event: &MessageEditorEvent,
447 window: &mut Window,
448 cx: &mut Context<Self>,
449 ) {
450 match event {
451 MessageEditorEvent::Send => self.send(window, cx),
452 MessageEditorEvent::Cancel => self.cancel_generation(cx),
453 MessageEditorEvent::Focus => {
454 self.cancel_editing(&Default::default(), window, cx);
455 }
456 }
457 }
458
459 pub fn handle_entry_view_event(
460 &mut self,
461 _: &Entity<EntryViewState>,
462 event: &EntryViewEvent,
463 window: &mut Window,
464 cx: &mut Context<Self>,
465 ) {
466 match &event.view_event {
467 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
468 self.editing_message = Some(event.entry_index);
469 cx.notify();
470 }
471 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
472 self.regenerate(event.entry_index, editor, window, cx);
473 }
474 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
475 self.cancel_editing(&Default::default(), window, cx);
476 }
477 }
478 }
479
480 fn resume_chat(&mut self, cx: &mut Context<Self>) {
481 self.thread_error.take();
482 let Some(thread) = self.thread() else {
483 return;
484 };
485
486 let task = thread.update(cx, |thread, cx| thread.resume(cx));
487 cx.spawn(async move |this, cx| {
488 let result = task.await;
489
490 this.update(cx, |this, cx| {
491 if let Err(err) = result {
492 this.handle_thread_error(err, cx);
493 }
494 })
495 })
496 .detach();
497 }
498
499 fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
500 if let Some(thread) = self.thread() {
501 if thread.read(cx).status() != ThreadStatus::Idle {
502 self.stop_current_and_send_new_message(window, cx);
503 return;
504 }
505 }
506
507 let contents = self
508 .message_editor
509 .update(cx, |message_editor, cx| message_editor.contents(window, cx));
510 self.send_impl(contents, window, cx)
511 }
512
513 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
514 let Some(thread) = self.thread().cloned() else {
515 return;
516 };
517
518 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
519
520 let contents = self
521 .message_editor
522 .update(cx, |message_editor, cx| message_editor.contents(window, cx));
523
524 cx.spawn_in(window, async move |this, cx| {
525 cancelled.await;
526
527 this.update_in(cx, |this, window, cx| {
528 this.send_impl(contents, window, cx);
529 })
530 .ok();
531 })
532 .detach();
533 }
534
535 fn send_impl(
536 &mut self,
537 contents: Task<anyhow::Result<Vec<acp::ContentBlock>>>,
538 window: &mut Window,
539 cx: &mut Context<Self>,
540 ) {
541 self.thread_error.take();
542 self.editing_message.take();
543
544 let Some(thread) = self.thread().cloned() else {
545 return;
546 };
547 let task = cx.spawn_in(window, async move |this, cx| {
548 let contents = contents.await?;
549
550 if contents.is_empty() {
551 return Ok(());
552 }
553
554 this.update_in(cx, |this, window, cx| {
555 this.set_editor_is_expanded(false, cx);
556 this.scroll_to_bottom(cx);
557 this.message_editor.update(cx, |message_editor, cx| {
558 message_editor.clear(window, cx);
559 });
560 })?;
561 let send = thread.update(cx, |thread, cx| thread.send(contents, cx))?;
562 send.await
563 });
564
565 cx.spawn(async move |this, cx| {
566 if let Err(err) = task.await {
567 this.update(cx, |this, cx| {
568 this.handle_thread_error(err, cx);
569 })
570 .ok();
571 }
572 })
573 .detach();
574 }
575
576 fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
577 let Some(thread) = self.thread().cloned() else {
578 return;
579 };
580
581 if let Some(index) = self.editing_message.take() {
582 if let Some(editor) = self
583 .entry_view_state
584 .read(cx)
585 .entry(index)
586 .and_then(|e| e.message_editor())
587 .cloned()
588 {
589 editor.update(cx, |editor, cx| {
590 if let Some(user_message) = thread
591 .read(cx)
592 .entries()
593 .get(index)
594 .and_then(|e| e.user_message())
595 {
596 editor.set_message(user_message.chunks.clone(), window, cx);
597 }
598 })
599 }
600 };
601 self.focus_handle(cx).focus(window);
602 cx.notify();
603 }
604
605 fn regenerate(
606 &mut self,
607 entry_ix: usize,
608 message_editor: &Entity<MessageEditor>,
609 window: &mut Window,
610 cx: &mut Context<Self>,
611 ) {
612 let Some(thread) = self.thread().cloned() else {
613 return;
614 };
615
616 let Some(rewind) = thread.update(cx, |thread, cx| {
617 let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?;
618 Some(thread.rewind(user_message_id, cx))
619 }) else {
620 return;
621 };
622
623 let contents =
624 message_editor.update(cx, |message_editor, cx| message_editor.contents(window, cx));
625
626 let task = cx.foreground_executor().spawn(async move {
627 rewind.await?;
628 contents.await
629 });
630 self.send_impl(task, window, cx);
631 }
632
633 fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
634 if let Some(thread) = self.thread() {
635 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
636 }
637 }
638
639 fn open_edited_buffer(
640 &mut self,
641 buffer: &Entity<Buffer>,
642 window: &mut Window,
643 cx: &mut Context<Self>,
644 ) {
645 let Some(thread) = self.thread() else {
646 return;
647 };
648
649 let Some(diff) =
650 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
651 else {
652 return;
653 };
654
655 diff.update(cx, |diff, cx| {
656 diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx)
657 })
658 }
659
660 fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
661 self.thread_error = Some(ThreadError::from_err(error));
662 cx.notify();
663 }
664
665 fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
666 self.thread_error = None;
667 cx.notify();
668 }
669
670 fn handle_thread_event(
671 &mut self,
672 thread: &Entity<AcpThread>,
673 event: &AcpThreadEvent,
674 window: &mut Window,
675 cx: &mut Context<Self>,
676 ) {
677 match event {
678 AcpThreadEvent::NewEntry => {
679 let len = thread.read(cx).entries().len();
680 let index = len - 1;
681 self.entry_view_state.update(cx, |view_state, cx| {
682 view_state.sync_entry(index, &thread, window, cx)
683 });
684 self.list_state.splice(index..index, 1);
685 }
686 AcpThreadEvent::EntryUpdated(index) => {
687 self.entry_view_state.update(cx, |view_state, cx| {
688 view_state.sync_entry(*index, &thread, window, cx)
689 });
690 self.list_state.splice(*index..index + 1, 1);
691 }
692 AcpThreadEvent::EntriesRemoved(range) => {
693 self.entry_view_state
694 .update(cx, |view_state, _cx| view_state.remove(range.clone()));
695 self.list_state.splice(range.clone(), 0);
696 }
697 AcpThreadEvent::ToolAuthorizationRequired => {
698 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
699 }
700 AcpThreadEvent::Stopped => {
701 let used_tools = thread.read(cx).used_tools_since_last_user_message();
702 self.notify_with_sound(
703 if used_tools {
704 "Finished running tools"
705 } else {
706 "New message"
707 },
708 IconName::ZedAssistant,
709 window,
710 cx,
711 );
712 }
713 AcpThreadEvent::Error => {
714 self.notify_with_sound(
715 "Agent stopped due to an error",
716 IconName::Warning,
717 window,
718 cx,
719 );
720 }
721 AcpThreadEvent::ServerExited(status) => {
722 self.thread_state = ThreadState::ServerExited { status: *status };
723 }
724 AcpThreadEvent::TitleUpdated => {}
725 }
726 cx.notify();
727 }
728
729 fn authenticate(
730 &mut self,
731 method: acp::AuthMethodId,
732 window: &mut Window,
733 cx: &mut Context<Self>,
734 ) {
735 let ThreadState::Unauthenticated { ref connection } = self.thread_state else {
736 return;
737 };
738
739 self.thread_error.take();
740 let authenticate = connection.authenticate(method, cx);
741 self.auth_task = Some(cx.spawn_in(window, {
742 let project = self.project.clone();
743 let agent = self.agent.clone();
744 async move |this, cx| {
745 let result = authenticate.await;
746
747 this.update_in(cx, |this, window, cx| {
748 if let Err(err) = result {
749 this.handle_thread_error(err, cx);
750 } else {
751 this.thread_state = Self::initial_state(
752 agent,
753 None, // todo!()
754 this.workspace.clone(),
755 project.clone(),
756 window,
757 cx,
758 )
759 }
760 this.auth_task.take()
761 })
762 .ok();
763 }
764 }));
765 }
766
767 fn authorize_tool_call(
768 &mut self,
769 tool_call_id: acp::ToolCallId,
770 option_id: acp::PermissionOptionId,
771 option_kind: acp::PermissionOptionKind,
772 cx: &mut Context<Self>,
773 ) {
774 let Some(thread) = self.thread() else {
775 return;
776 };
777 thread.update(cx, |thread, cx| {
778 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
779 });
780 cx.notify();
781 }
782
783 fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
784 let Some(thread) = self.thread() else {
785 return;
786 };
787 thread
788 .update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
789 .detach_and_log_err(cx);
790 cx.notify();
791 }
792
793 fn render_entry(
794 &self,
795 entry_ix: usize,
796 total_entries: usize,
797 entry: &AgentThreadEntry,
798 window: &mut Window,
799 cx: &Context<Self>,
800 ) -> AnyElement {
801 let primary = match &entry {
802 AgentThreadEntry::UserMessage(message) => {
803 let Some(editor) = self
804 .entry_view_state
805 .read(cx)
806 .entry(entry_ix)
807 .and_then(|entry| entry.message_editor())
808 .cloned()
809 else {
810 return Empty.into_any_element();
811 };
812
813 let editing = self.editing_message == Some(entry_ix);
814 let editor_focus = editor.focus_handle(cx).is_focused(window);
815 let focus_border = cx.theme().colors().border_focused;
816
817 div()
818 .id(("user_message", entry_ix))
819 .py_4()
820 .px_2()
821 .children(message.id.clone().and_then(|message_id| {
822 message.checkpoint.as_ref()?.show.then(|| {
823 Button::new("restore-checkpoint", "Restore Checkpoint")
824 .icon(IconName::Undo)
825 .icon_size(IconSize::XSmall)
826 .icon_position(IconPosition::Start)
827 .label_size(LabelSize::XSmall)
828 .on_click(cx.listener(move |this, _, _window, cx| {
829 this.rewind(&message_id, cx);
830 }))
831 })
832 }))
833 .child(
834 div()
835 .relative()
836 .child(
837 div()
838 .p_3()
839 .rounded_lg()
840 .shadow_md()
841 .bg(cx.theme().colors().editor_background)
842 .border_1()
843 .when(editing && !editor_focus, |this| this.border_dashed())
844 .border_color(cx.theme().colors().border)
845 .map(|this|{
846 if editor_focus {
847 this.border_color(focus_border)
848 } else {
849 this.hover(|s| s.border_color(focus_border.opacity(0.8)))
850 }
851 })
852 .text_xs()
853 .child(editor.clone().into_any_element()),
854 )
855 .when(editor_focus, |this|
856 this.child(
857 h_flex()
858 .absolute()
859 .top_neg_3p5()
860 .right_3()
861 .gap_1()
862 .rounded_sm()
863 .border_1()
864 .border_color(cx.theme().colors().border)
865 .bg(cx.theme().colors().editor_background)
866 .overflow_hidden()
867 .child(
868 IconButton::new("cancel", IconName::Close)
869 .icon_color(Color::Error)
870 .icon_size(IconSize::XSmall)
871 .on_click(cx.listener(Self::cancel_editing))
872 )
873 .child(
874 IconButton::new("regenerate", IconName::Return)
875 .icon_color(Color::Muted)
876 .icon_size(IconSize::XSmall)
877 .tooltip(Tooltip::text(
878 "Editing will restart the thread from this point."
879 ))
880 .on_click(cx.listener({
881 let editor = editor.clone();
882 move |this, _, window, cx| {
883 this.regenerate(
884 entry_ix, &editor, window, cx,
885 );
886 }
887 })),
888 )
889 )
890 ),
891 )
892 .into_any()
893 }
894 AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
895 let style = default_markdown_style(false, window, cx);
896 let message_body = v_flex()
897 .w_full()
898 .gap_2p5()
899 .children(chunks.iter().enumerate().filter_map(
900 |(chunk_ix, chunk)| match chunk {
901 AssistantMessageChunk::Message { block } => {
902 block.markdown().map(|md| {
903 self.render_markdown(md.clone(), style.clone())
904 .into_any_element()
905 })
906 }
907 AssistantMessageChunk::Thought { block } => {
908 block.markdown().map(|md| {
909 self.render_thinking_block(
910 entry_ix,
911 chunk_ix,
912 md.clone(),
913 window,
914 cx,
915 )
916 .into_any_element()
917 })
918 }
919 },
920 ))
921 .into_any();
922
923 v_flex()
924 .px_5()
925 .py_1()
926 .when(entry_ix + 1 == total_entries, |this| this.pb_4())
927 .w_full()
928 .text_ui(cx)
929 .child(message_body)
930 .into_any()
931 }
932 AgentThreadEntry::ToolCall(tool_call) => {
933 let has_terminals = tool_call.terminals().next().is_some();
934
935 div().w_full().py_1p5().px_5().map(|this| {
936 if has_terminals {
937 this.children(tool_call.terminals().map(|terminal| {
938 self.render_terminal_tool_call(
939 entry_ix, terminal, tool_call, window, cx,
940 )
941 }))
942 } else {
943 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
944 }
945 })
946 }
947 .into_any(),
948 };
949
950 let Some(thread) = self.thread() else {
951 return primary;
952 };
953
954 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
955 let primary = if entry_ix == total_entries - 1 && !is_generating {
956 v_flex()
957 .w_full()
958 .child(primary)
959 .child(self.render_thread_controls(cx))
960 .into_any_element()
961 } else {
962 primary
963 };
964
965 if let Some(editing_index) = self.editing_message.as_ref()
966 && *editing_index < entry_ix
967 {
968 div()
969 .child(primary)
970 .opacity(0.2)
971 .block_mouse_except_scroll()
972 .id("overlay")
973 .on_click(cx.listener(Self::cancel_editing))
974 .into_any_element()
975 } else {
976 primary
977 }
978 }
979
980 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
981 cx.theme()
982 .colors()
983 .element_background
984 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
985 }
986
987 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
988 cx.theme().colors().border.opacity(0.6)
989 }
990
991 fn tool_name_font_size(&self) -> Rems {
992 rems_from_px(13.)
993 }
994
995 fn render_thinking_block(
996 &self,
997 entry_ix: usize,
998 chunk_ix: usize,
999 chunk: Entity<Markdown>,
1000 window: &Window,
1001 cx: &Context<Self>,
1002 ) -> AnyElement {
1003 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
1004 let card_header_id = SharedString::from("inner-card-header");
1005 let key = (entry_ix, chunk_ix);
1006 let is_open = self.expanded_thinking_blocks.contains(&key);
1007
1008 v_flex()
1009 .child(
1010 h_flex()
1011 .id(header_id)
1012 .group(&card_header_id)
1013 .relative()
1014 .w_full()
1015 .gap_1p5()
1016 .opacity(0.8)
1017 .hover(|style| style.opacity(1.))
1018 .child(
1019 h_flex()
1020 .size_4()
1021 .justify_center()
1022 .child(
1023 div()
1024 .group_hover(&card_header_id, |s| s.invisible().w_0())
1025 .child(
1026 Icon::new(IconName::ToolThink)
1027 .size(IconSize::Small)
1028 .color(Color::Muted),
1029 ),
1030 )
1031 .child(
1032 h_flex()
1033 .absolute()
1034 .inset_0()
1035 .invisible()
1036 .justify_center()
1037 .group_hover(&card_header_id, |s| s.visible())
1038 .child(
1039 Disclosure::new(("expand", entry_ix), is_open)
1040 .opened_icon(IconName::ChevronUp)
1041 .closed_icon(IconName::ChevronRight)
1042 .on_click(cx.listener({
1043 move |this, _event, _window, cx| {
1044 if is_open {
1045 this.expanded_thinking_blocks.remove(&key);
1046 } else {
1047 this.expanded_thinking_blocks.insert(key);
1048 }
1049 cx.notify();
1050 }
1051 })),
1052 ),
1053 ),
1054 )
1055 .child(
1056 div()
1057 .text_size(self.tool_name_font_size())
1058 .child("Thinking"),
1059 )
1060 .on_click(cx.listener({
1061 move |this, _event, _window, cx| {
1062 if is_open {
1063 this.expanded_thinking_blocks.remove(&key);
1064 } else {
1065 this.expanded_thinking_blocks.insert(key);
1066 }
1067 cx.notify();
1068 }
1069 })),
1070 )
1071 .when(is_open, |this| {
1072 this.child(
1073 div()
1074 .relative()
1075 .mt_1p5()
1076 .ml(px(7.))
1077 .pl_4()
1078 .border_l_1()
1079 .border_color(self.tool_card_border_color(cx))
1080 .text_ui_sm(cx)
1081 .child(
1082 self.render_markdown(chunk, default_markdown_style(false, window, cx)),
1083 ),
1084 )
1085 })
1086 .into_any_element()
1087 }
1088
1089 fn render_tool_call_icon(
1090 &self,
1091 group_name: SharedString,
1092 entry_ix: usize,
1093 is_collapsible: bool,
1094 is_open: bool,
1095 tool_call: &ToolCall,
1096 cx: &Context<Self>,
1097 ) -> Div {
1098 let tool_icon = Icon::new(match tool_call.kind {
1099 acp::ToolKind::Read => IconName::ToolRead,
1100 acp::ToolKind::Edit => IconName::ToolPencil,
1101 acp::ToolKind::Delete => IconName::ToolDeleteFile,
1102 acp::ToolKind::Move => IconName::ArrowRightLeft,
1103 acp::ToolKind::Search => IconName::ToolSearch,
1104 acp::ToolKind::Execute => IconName::ToolTerminal,
1105 acp::ToolKind::Think => IconName::ToolThink,
1106 acp::ToolKind::Fetch => IconName::ToolWeb,
1107 acp::ToolKind::Other => IconName::ToolHammer,
1108 })
1109 .size(IconSize::Small)
1110 .color(Color::Muted);
1111
1112 let base_container = h_flex().size_4().justify_center();
1113
1114 if is_collapsible {
1115 base_container
1116 .child(
1117 div()
1118 .group_hover(&group_name, |s| s.invisible().w_0())
1119 .child(tool_icon),
1120 )
1121 .child(
1122 h_flex()
1123 .absolute()
1124 .inset_0()
1125 .invisible()
1126 .justify_center()
1127 .group_hover(&group_name, |s| s.visible())
1128 .child(
1129 Disclosure::new(("expand", entry_ix), is_open)
1130 .opened_icon(IconName::ChevronUp)
1131 .closed_icon(IconName::ChevronRight)
1132 .on_click(cx.listener({
1133 let id = tool_call.id.clone();
1134 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1135 if is_open {
1136 this.expanded_tool_calls.remove(&id);
1137 } else {
1138 this.expanded_tool_calls.insert(id.clone());
1139 }
1140 cx.notify();
1141 }
1142 })),
1143 ),
1144 )
1145 } else {
1146 base_container.child(tool_icon)
1147 }
1148 }
1149
1150 fn render_tool_call(
1151 &self,
1152 entry_ix: usize,
1153 tool_call: &ToolCall,
1154 window: &Window,
1155 cx: &Context<Self>,
1156 ) -> Div {
1157 let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
1158 let card_header_id = SharedString::from("inner-tool-call-header");
1159
1160 let status_icon = match &tool_call.status {
1161 ToolCallStatus::Pending
1162 | ToolCallStatus::WaitingForConfirmation { .. }
1163 | ToolCallStatus::Completed => None,
1164 ToolCallStatus::InProgress => Some(
1165 Icon::new(IconName::ArrowCircle)
1166 .color(Color::Accent)
1167 .size(IconSize::Small)
1168 .with_animation(
1169 "running",
1170 Animation::new(Duration::from_secs(2)).repeat(),
1171 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1172 )
1173 .into_any(),
1174 ),
1175 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => Some(
1176 Icon::new(IconName::Close)
1177 .color(Color::Error)
1178 .size(IconSize::Small)
1179 .into_any_element(),
1180 ),
1181 };
1182
1183 let needs_confirmation = matches!(
1184 tool_call.status,
1185 ToolCallStatus::WaitingForConfirmation { .. }
1186 );
1187 let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit);
1188 let has_diff = tool_call
1189 .content
1190 .iter()
1191 .any(|content| matches!(content, ToolCallContent::Diff { .. }));
1192 let has_nonempty_diff = tool_call.content.iter().any(|content| match content {
1193 ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx),
1194 _ => false,
1195 });
1196 let use_card_layout = needs_confirmation || is_edit || has_diff;
1197
1198 let is_collapsible = !tool_call.content.is_empty() && !use_card_layout;
1199
1200 let is_open = tool_call.content.is_empty()
1201 || needs_confirmation
1202 || has_nonempty_diff
1203 || self.expanded_tool_calls.contains(&tool_call.id);
1204
1205 let gradient_overlay = |color: Hsla| {
1206 div()
1207 .absolute()
1208 .top_0()
1209 .right_0()
1210 .w_12()
1211 .h_full()
1212 .bg(linear_gradient(
1213 90.,
1214 linear_color_stop(color, 1.),
1215 linear_color_stop(color.opacity(0.2), 0.),
1216 ))
1217 };
1218 let gradient_color = if use_card_layout {
1219 self.tool_card_header_bg(cx)
1220 } else {
1221 cx.theme().colors().panel_background
1222 };
1223
1224 let tool_output_display = match &tool_call.status {
1225 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
1226 .w_full()
1227 .children(tool_call.content.iter().map(|content| {
1228 div()
1229 .child(
1230 self.render_tool_call_content(entry_ix, content, tool_call, window, cx),
1231 )
1232 .into_any_element()
1233 }))
1234 .child(self.render_permission_buttons(
1235 options,
1236 entry_ix,
1237 tool_call.id.clone(),
1238 tool_call.content.is_empty(),
1239 cx,
1240 )),
1241 ToolCallStatus::Pending
1242 | ToolCallStatus::InProgress
1243 | ToolCallStatus::Completed
1244 | ToolCallStatus::Failed
1245 | ToolCallStatus::Canceled => {
1246 v_flex()
1247 .w_full()
1248 .children(tool_call.content.iter().map(|content| {
1249 div()
1250 .child(
1251 self.render_tool_call_content(
1252 entry_ix, content, tool_call, window, cx,
1253 ),
1254 )
1255 .into_any_element()
1256 }))
1257 }
1258 ToolCallStatus::Rejected => v_flex().size_0(),
1259 };
1260
1261 v_flex()
1262 .when(use_card_layout, |this| {
1263 this.rounded_lg()
1264 .border_1()
1265 .border_color(self.tool_card_border_color(cx))
1266 .bg(cx.theme().colors().editor_background)
1267 .overflow_hidden()
1268 })
1269 .child(
1270 h_flex()
1271 .id(header_id)
1272 .w_full()
1273 .gap_1()
1274 .justify_between()
1275 .map(|this| {
1276 if use_card_layout {
1277 this.pl_2()
1278 .pr_1()
1279 .py_1()
1280 .rounded_t_md()
1281 .bg(self.tool_card_header_bg(cx))
1282 } else {
1283 this.opacity(0.8).hover(|style| style.opacity(1.))
1284 }
1285 })
1286 .child(
1287 h_flex()
1288 .group(&card_header_id)
1289 .relative()
1290 .w_full()
1291 .text_size(self.tool_name_font_size())
1292 .child(self.render_tool_call_icon(
1293 card_header_id,
1294 entry_ix,
1295 is_collapsible,
1296 is_open,
1297 tool_call,
1298 cx,
1299 ))
1300 .child(if tool_call.locations.len() == 1 {
1301 let name = tool_call.locations[0]
1302 .path
1303 .file_name()
1304 .unwrap_or_default()
1305 .display()
1306 .to_string();
1307
1308 h_flex()
1309 .id(("open-tool-call-location", entry_ix))
1310 .w_full()
1311 .max_w_full()
1312 .px_1p5()
1313 .rounded_sm()
1314 .overflow_x_scroll()
1315 .opacity(0.8)
1316 .hover(|label| {
1317 label.opacity(1.).bg(cx
1318 .theme()
1319 .colors()
1320 .element_hover
1321 .opacity(0.5))
1322 })
1323 .child(name)
1324 .tooltip(Tooltip::text("Jump to File"))
1325 .on_click(cx.listener(move |this, _, window, cx| {
1326 this.open_tool_call_location(entry_ix, 0, window, cx);
1327 }))
1328 .into_any_element()
1329 } else {
1330 h_flex()
1331 .id("non-card-label-container")
1332 .w_full()
1333 .relative()
1334 .ml_1p5()
1335 .overflow_hidden()
1336 .child(
1337 h_flex()
1338 .id("non-card-label")
1339 .pr_8()
1340 .w_full()
1341 .overflow_x_scroll()
1342 .child(self.render_markdown(
1343 tool_call.label.clone(),
1344 default_markdown_style(
1345 needs_confirmation || is_edit || has_diff,
1346 window,
1347 cx,
1348 ),
1349 )),
1350 )
1351 .child(gradient_overlay(gradient_color))
1352 .on_click(cx.listener({
1353 let id = tool_call.id.clone();
1354 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1355 if is_open {
1356 this.expanded_tool_calls.remove(&id);
1357 } else {
1358 this.expanded_tool_calls.insert(id.clone());
1359 }
1360 cx.notify();
1361 }
1362 }))
1363 .into_any()
1364 }),
1365 )
1366 .children(status_icon),
1367 )
1368 .when(is_open, |this| this.child(tool_output_display))
1369 }
1370
1371 fn render_tool_call_content(
1372 &self,
1373 entry_ix: usize,
1374 content: &ToolCallContent,
1375 tool_call: &ToolCall,
1376 window: &Window,
1377 cx: &Context<Self>,
1378 ) -> AnyElement {
1379 match content {
1380 ToolCallContent::ContentBlock(content) => {
1381 if let Some(resource_link) = content.resource_link() {
1382 self.render_resource_link(resource_link, cx)
1383 } else if let Some(markdown) = content.markdown() {
1384 self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx)
1385 } else {
1386 Empty.into_any_element()
1387 }
1388 }
1389 ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, &diff, cx),
1390 ToolCallContent::Terminal(terminal) => {
1391 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
1392 }
1393 }
1394 }
1395
1396 fn render_markdown_output(
1397 &self,
1398 markdown: Entity<Markdown>,
1399 tool_call_id: acp::ToolCallId,
1400 window: &Window,
1401 cx: &Context<Self>,
1402 ) -> AnyElement {
1403 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id.clone()));
1404
1405 v_flex()
1406 .mt_1p5()
1407 .ml(px(7.))
1408 .px_3p5()
1409 .gap_2()
1410 .border_l_1()
1411 .border_color(self.tool_card_border_color(cx))
1412 .text_sm()
1413 .text_color(cx.theme().colors().text_muted)
1414 .child(self.render_markdown(markdown, default_markdown_style(false, window, cx)))
1415 .child(
1416 Button::new(button_id, "Collapse Output")
1417 .full_width()
1418 .style(ButtonStyle::Outlined)
1419 .label_size(LabelSize::Small)
1420 .icon(IconName::ChevronUp)
1421 .icon_color(Color::Muted)
1422 .icon_position(IconPosition::Start)
1423 .on_click(cx.listener({
1424 let id = tool_call_id.clone();
1425 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1426 this.expanded_tool_calls.remove(&id);
1427 cx.notify();
1428 }
1429 })),
1430 )
1431 .into_any_element()
1432 }
1433
1434 fn render_resource_link(
1435 &self,
1436 resource_link: &acp::ResourceLink,
1437 cx: &Context<Self>,
1438 ) -> AnyElement {
1439 let uri: SharedString = resource_link.uri.clone().into();
1440
1441 let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") {
1442 path.to_string().into()
1443 } else {
1444 uri.clone()
1445 };
1446
1447 let button_id = SharedString::from(format!("item-{}", uri.clone()));
1448
1449 div()
1450 .ml(px(7.))
1451 .pl_2p5()
1452 .border_l_1()
1453 .border_color(self.tool_card_border_color(cx))
1454 .overflow_hidden()
1455 .child(
1456 Button::new(button_id, label)
1457 .label_size(LabelSize::Small)
1458 .color(Color::Muted)
1459 .icon(IconName::ArrowUpRight)
1460 .icon_size(IconSize::XSmall)
1461 .icon_color(Color::Muted)
1462 .truncate(true)
1463 .on_click(cx.listener({
1464 let workspace = self.workspace.clone();
1465 move |_, _, window, cx: &mut Context<Self>| {
1466 Self::open_link(uri.clone(), &workspace, window, cx);
1467 }
1468 })),
1469 )
1470 .into_any_element()
1471 }
1472
1473 fn render_permission_buttons(
1474 &self,
1475 options: &[acp::PermissionOption],
1476 entry_ix: usize,
1477 tool_call_id: acp::ToolCallId,
1478 empty_content: bool,
1479 cx: &Context<Self>,
1480 ) -> Div {
1481 h_flex()
1482 .py_1()
1483 .pl_2()
1484 .pr_1()
1485 .gap_1()
1486 .justify_between()
1487 .flex_wrap()
1488 .when(!empty_content, |this| {
1489 this.border_t_1()
1490 .border_color(self.tool_card_border_color(cx))
1491 })
1492 .child(
1493 div()
1494 .min_w(rems_from_px(145.))
1495 .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
1496 )
1497 .child(h_flex().gap_0p5().children(options.iter().map(|option| {
1498 let option_id = SharedString::from(option.id.0.clone());
1499 Button::new((option_id, entry_ix), option.name.clone())
1500 .map(|this| match option.kind {
1501 acp::PermissionOptionKind::AllowOnce => {
1502 this.icon(IconName::Check).icon_color(Color::Success)
1503 }
1504 acp::PermissionOptionKind::AllowAlways => {
1505 this.icon(IconName::CheckDouble).icon_color(Color::Success)
1506 }
1507 acp::PermissionOptionKind::RejectOnce => {
1508 this.icon(IconName::Close).icon_color(Color::Error)
1509 }
1510 acp::PermissionOptionKind::RejectAlways => {
1511 this.icon(IconName::Close).icon_color(Color::Error)
1512 }
1513 })
1514 .icon_position(IconPosition::Start)
1515 .icon_size(IconSize::XSmall)
1516 .label_size(LabelSize::Small)
1517 .on_click(cx.listener({
1518 let tool_call_id = tool_call_id.clone();
1519 let option_id = option.id.clone();
1520 let option_kind = option.kind;
1521 move |this, _, _, cx| {
1522 this.authorize_tool_call(
1523 tool_call_id.clone(),
1524 option_id.clone(),
1525 option_kind,
1526 cx,
1527 );
1528 }
1529 }))
1530 })))
1531 }
1532
1533 fn render_diff_editor(
1534 &self,
1535 entry_ix: usize,
1536 diff: &Entity<acp_thread::Diff>,
1537 cx: &Context<Self>,
1538 ) -> AnyElement {
1539 v_flex()
1540 .h_full()
1541 .border_t_1()
1542 .border_color(self.tool_card_border_color(cx))
1543 .child(
1544 if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
1545 && let Some(editor) = entry.editor_for_diff(&diff)
1546 {
1547 editor.clone().into_any_element()
1548 } else {
1549 Empty.into_any()
1550 },
1551 )
1552 .into_any()
1553 }
1554
1555 fn render_terminal_tool_call(
1556 &self,
1557 entry_ix: usize,
1558 terminal: &Entity<acp_thread::Terminal>,
1559 tool_call: &ToolCall,
1560 window: &Window,
1561 cx: &Context<Self>,
1562 ) -> AnyElement {
1563 let terminal_data = terminal.read(cx);
1564 let working_dir = terminal_data.working_dir();
1565 let command = terminal_data.command();
1566 let started_at = terminal_data.started_at();
1567
1568 let tool_failed = matches!(
1569 &tool_call.status,
1570 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
1571 );
1572
1573 let output = terminal_data.output();
1574 let command_finished = output.is_some();
1575 let truncated_output = output.is_some_and(|output| output.was_content_truncated);
1576 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
1577
1578 let command_failed = command_finished
1579 && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success()));
1580
1581 let time_elapsed = if let Some(output) = output {
1582 output.ended_at.duration_since(started_at)
1583 } else {
1584 started_at.elapsed()
1585 };
1586
1587 let header_bg = cx
1588 .theme()
1589 .colors()
1590 .element_background
1591 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
1592 let border_color = cx.theme().colors().border.opacity(0.6);
1593
1594 let working_dir = working_dir
1595 .as_ref()
1596 .map(|path| format!("{}", path.display()))
1597 .unwrap_or_else(|| "current directory".to_string());
1598
1599 let header = h_flex()
1600 .id(SharedString::from(format!(
1601 "terminal-tool-header-{}",
1602 terminal.entity_id()
1603 )))
1604 .flex_none()
1605 .gap_1()
1606 .justify_between()
1607 .rounded_t_md()
1608 .child(
1609 div()
1610 .id(("command-target-path", terminal.entity_id()))
1611 .w_full()
1612 .max_w_full()
1613 .overflow_x_scroll()
1614 .child(
1615 Label::new(working_dir)
1616 .buffer_font(cx)
1617 .size(LabelSize::XSmall)
1618 .color(Color::Muted),
1619 ),
1620 )
1621 .when(!command_finished, |header| {
1622 header
1623 .gap_1p5()
1624 .child(
1625 Button::new(
1626 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
1627 "Stop",
1628 )
1629 .icon(IconName::Stop)
1630 .icon_position(IconPosition::Start)
1631 .icon_size(IconSize::Small)
1632 .icon_color(Color::Error)
1633 .label_size(LabelSize::Small)
1634 .tooltip(move |window, cx| {
1635 Tooltip::with_meta(
1636 "Stop This Command",
1637 None,
1638 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
1639 window,
1640 cx,
1641 )
1642 })
1643 .on_click({
1644 let terminal = terminal.clone();
1645 cx.listener(move |_this, _event, _window, cx| {
1646 let inner_terminal = terminal.read(cx).inner().clone();
1647 inner_terminal.update(cx, |inner_terminal, _cx| {
1648 inner_terminal.kill_active_task();
1649 });
1650 })
1651 }),
1652 )
1653 .child(Divider::vertical())
1654 .child(
1655 Icon::new(IconName::ArrowCircle)
1656 .size(IconSize::XSmall)
1657 .color(Color::Info)
1658 .with_animation(
1659 "arrow-circle",
1660 Animation::new(Duration::from_secs(2)).repeat(),
1661 |icon, delta| {
1662 icon.transform(Transformation::rotate(percentage(delta)))
1663 },
1664 ),
1665 )
1666 })
1667 .when(tool_failed || command_failed, |header| {
1668 header.child(
1669 div()
1670 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
1671 .child(
1672 Icon::new(IconName::Close)
1673 .size(IconSize::Small)
1674 .color(Color::Error),
1675 )
1676 .when_some(output.and_then(|o| o.exit_status), |this, status| {
1677 this.tooltip(Tooltip::text(format!(
1678 "Exited with code {}",
1679 status.code().unwrap_or(-1),
1680 )))
1681 }),
1682 )
1683 })
1684 .when(truncated_output, |header| {
1685 let tooltip = if let Some(output) = output {
1686 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
1687 "Output exceeded terminal max lines and was \
1688 truncated, the model received the first 16 KB."
1689 .to_string()
1690 } else {
1691 format!(
1692 "Output is {} long—to avoid unexpected token usage, \
1693 only 16 KB was sent back to the model.",
1694 format_file_size(output.original_content_len as u64, true),
1695 )
1696 }
1697 } else {
1698 "Output was truncated".to_string()
1699 };
1700
1701 header.child(
1702 h_flex()
1703 .id(("terminal-tool-truncated-label", terminal.entity_id()))
1704 .gap_1()
1705 .child(
1706 Icon::new(IconName::Info)
1707 .size(IconSize::XSmall)
1708 .color(Color::Ignored),
1709 )
1710 .child(
1711 Label::new("Truncated")
1712 .color(Color::Muted)
1713 .size(LabelSize::XSmall),
1714 )
1715 .tooltip(Tooltip::text(tooltip)),
1716 )
1717 })
1718 .when(time_elapsed > Duration::from_secs(10), |header| {
1719 header.child(
1720 Label::new(format!("({})", duration_alt_display(time_elapsed)))
1721 .buffer_font(cx)
1722 .color(Color::Muted)
1723 .size(LabelSize::XSmall),
1724 )
1725 })
1726 .child(
1727 Disclosure::new(
1728 SharedString::from(format!(
1729 "terminal-tool-disclosure-{}",
1730 terminal.entity_id()
1731 )),
1732 self.terminal_expanded,
1733 )
1734 .opened_icon(IconName::ChevronUp)
1735 .closed_icon(IconName::ChevronDown)
1736 .on_click(cx.listener(move |this, _event, _window, _cx| {
1737 this.terminal_expanded = !this.terminal_expanded;
1738 })),
1739 );
1740
1741 let terminal_view = self
1742 .entry_view_state
1743 .read(cx)
1744 .entry(entry_ix)
1745 .and_then(|entry| entry.terminal(&terminal));
1746 let show_output = self.terminal_expanded && terminal_view.is_some();
1747
1748 v_flex()
1749 .mb_2()
1750 .border_1()
1751 .when(tool_failed || command_failed, |card| card.border_dashed())
1752 .border_color(border_color)
1753 .rounded_lg()
1754 .overflow_hidden()
1755 .child(
1756 v_flex()
1757 .py_1p5()
1758 .pl_2()
1759 .pr_1p5()
1760 .gap_0p5()
1761 .bg(header_bg)
1762 .text_xs()
1763 .child(header)
1764 .child(
1765 MarkdownElement::new(
1766 command.clone(),
1767 terminal_command_markdown_style(window, cx),
1768 )
1769 .code_block_renderer(
1770 markdown::CodeBlockRenderer::Default {
1771 copy_button: false,
1772 copy_button_on_hover: true,
1773 border: false,
1774 },
1775 ),
1776 ),
1777 )
1778 .when(show_output, |this| {
1779 this.child(
1780 div()
1781 .pt_2()
1782 .border_t_1()
1783 .when(tool_failed || command_failed, |card| card.border_dashed())
1784 .border_color(border_color)
1785 .bg(cx.theme().colors().editor_background)
1786 .rounded_b_md()
1787 .text_ui_sm(cx)
1788 .children(terminal_view.clone()),
1789 )
1790 })
1791 .into_any()
1792 }
1793
1794 fn render_agent_logo(&self) -> AnyElement {
1795 Icon::new(self.agent.logo())
1796 .color(Color::Muted)
1797 .size(IconSize::XLarge)
1798 .into_any_element()
1799 }
1800
1801 fn render_error_agent_logo(&self) -> AnyElement {
1802 let logo = Icon::new(self.agent.logo())
1803 .color(Color::Muted)
1804 .size(IconSize::XLarge)
1805 .into_any_element();
1806
1807 h_flex()
1808 .relative()
1809 .justify_center()
1810 .child(div().opacity(0.3).child(logo))
1811 .child(
1812 h_flex().absolute().right_1().bottom_0().child(
1813 Icon::new(IconName::XCircle)
1814 .color(Color::Error)
1815 .size(IconSize::Small),
1816 ),
1817 )
1818 .into_any_element()
1819 }
1820
1821 fn render_empty_state(&self, cx: &App) -> AnyElement {
1822 let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
1823
1824 v_flex()
1825 .size_full()
1826 .items_center()
1827 .justify_center()
1828 .child(if loading {
1829 h_flex()
1830 .justify_center()
1831 .child(self.render_agent_logo())
1832 .with_animation(
1833 "pulsating_icon",
1834 Animation::new(Duration::from_secs(2))
1835 .repeat()
1836 .with_easing(pulsating_between(0.4, 1.0)),
1837 |icon, delta| icon.opacity(delta),
1838 )
1839 .into_any()
1840 } else {
1841 self.render_agent_logo().into_any_element()
1842 })
1843 .child(h_flex().mt_4().mb_1().justify_center().child(if loading {
1844 div()
1845 .child(LoadingLabel::new("").size(LabelSize::Large))
1846 .into_any_element()
1847 } else {
1848 Headline::new(self.agent.empty_state_headline())
1849 .size(HeadlineSize::Medium)
1850 .into_any_element()
1851 }))
1852 .child(
1853 div()
1854 .max_w_1_2()
1855 .text_sm()
1856 .text_center()
1857 .map(|this| {
1858 if loading {
1859 this.invisible()
1860 } else {
1861 this.text_color(cx.theme().colors().text_muted)
1862 }
1863 })
1864 .child(self.agent.empty_state_message()),
1865 )
1866 .into_any()
1867 }
1868
1869 fn render_pending_auth_state(&self) -> AnyElement {
1870 v_flex()
1871 .items_center()
1872 .justify_center()
1873 .child(self.render_error_agent_logo())
1874 .child(
1875 h_flex()
1876 .mt_4()
1877 .mb_1()
1878 .justify_center()
1879 .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
1880 )
1881 .into_any()
1882 }
1883
1884 fn render_server_exited(&self, status: ExitStatus, _cx: &Context<Self>) -> AnyElement {
1885 v_flex()
1886 .items_center()
1887 .justify_center()
1888 .child(self.render_error_agent_logo())
1889 .child(
1890 v_flex()
1891 .mt_4()
1892 .mb_2()
1893 .gap_0p5()
1894 .text_center()
1895 .items_center()
1896 .child(Headline::new("Server exited unexpectedly").size(HeadlineSize::Medium))
1897 .child(
1898 Label::new(format!("Exit status: {}", status.code().unwrap_or(-127)))
1899 .size(LabelSize::Small)
1900 .color(Color::Muted),
1901 ),
1902 )
1903 .into_any_element()
1904 }
1905
1906 fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1907 let mut container = v_flex()
1908 .items_center()
1909 .justify_center()
1910 .child(self.render_error_agent_logo())
1911 .child(
1912 v_flex()
1913 .mt_4()
1914 .mb_2()
1915 .gap_0p5()
1916 .text_center()
1917 .items_center()
1918 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1919 .child(
1920 Label::new(e.to_string())
1921 .size(LabelSize::Small)
1922 .color(Color::Muted),
1923 ),
1924 );
1925
1926 if let LoadError::Unsupported {
1927 upgrade_message,
1928 upgrade_command,
1929 ..
1930 } = &e
1931 {
1932 let upgrade_message = upgrade_message.clone();
1933 let upgrade_command = upgrade_command.clone();
1934 container = container.child(Button::new("upgrade", upgrade_message).on_click(
1935 cx.listener(move |this, _, window, cx| {
1936 this.workspace
1937 .update(cx, |workspace, cx| {
1938 let project = workspace.project().read(cx);
1939 let cwd = project.first_project_directory(cx);
1940 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1941 let spawn_in_terminal = task::SpawnInTerminal {
1942 id: task::TaskId("install".to_string()),
1943 full_label: upgrade_command.clone(),
1944 label: upgrade_command.clone(),
1945 command: Some(upgrade_command.clone()),
1946 args: Vec::new(),
1947 command_label: upgrade_command.clone(),
1948 cwd,
1949 env: Default::default(),
1950 use_new_terminal: true,
1951 allow_concurrent_runs: true,
1952 reveal: Default::default(),
1953 reveal_target: Default::default(),
1954 hide: Default::default(),
1955 shell,
1956 show_summary: true,
1957 show_command: true,
1958 show_rerun: false,
1959 };
1960 workspace
1961 .spawn_in_terminal(spawn_in_terminal, window, cx)
1962 .detach();
1963 })
1964 .ok();
1965 }),
1966 ));
1967 }
1968
1969 container.into_any()
1970 }
1971
1972 fn render_activity_bar(
1973 &self,
1974 thread_entity: &Entity<AcpThread>,
1975 window: &mut Window,
1976 cx: &Context<Self>,
1977 ) -> Option<AnyElement> {
1978 let thread = thread_entity.read(cx);
1979 let action_log = thread.action_log();
1980 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1981 let plan = thread.plan();
1982
1983 if changed_buffers.is_empty() && plan.is_empty() {
1984 return None;
1985 }
1986
1987 let editor_bg_color = cx.theme().colors().editor_background;
1988 let active_color = cx.theme().colors().element_selected;
1989 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
1990
1991 let pending_edits = thread.has_pending_edit_tool_calls();
1992
1993 v_flex()
1994 .mt_1()
1995 .mx_2()
1996 .bg(bg_edit_files_disclosure)
1997 .border_1()
1998 .border_b_0()
1999 .border_color(cx.theme().colors().border)
2000 .rounded_t_md()
2001 .shadow(vec![gpui::BoxShadow {
2002 color: gpui::black().opacity(0.15),
2003 offset: point(px(1.), px(-1.)),
2004 blur_radius: px(3.),
2005 spread_radius: px(0.),
2006 }])
2007 .when(!plan.is_empty(), |this| {
2008 this.child(self.render_plan_summary(plan, window, cx))
2009 .when(self.plan_expanded, |parent| {
2010 parent.child(self.render_plan_entries(plan, window, cx))
2011 })
2012 })
2013 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2014 this.child(Divider::horizontal().color(DividerColor::Border))
2015 })
2016 .when(!changed_buffers.is_empty(), |this| {
2017 this.child(self.render_edits_summary(
2018 action_log,
2019 &changed_buffers,
2020 self.edits_expanded,
2021 pending_edits,
2022 window,
2023 cx,
2024 ))
2025 .when(self.edits_expanded, |parent| {
2026 parent.child(self.render_edited_files(
2027 action_log,
2028 &changed_buffers,
2029 pending_edits,
2030 cx,
2031 ))
2032 })
2033 })
2034 .into_any()
2035 .into()
2036 }
2037
2038 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2039 let stats = plan.stats();
2040
2041 let title = if let Some(entry) = stats.in_progress_entry
2042 && !self.plan_expanded
2043 {
2044 h_flex()
2045 .w_full()
2046 .cursor_default()
2047 .gap_1()
2048 .text_xs()
2049 .text_color(cx.theme().colors().text_muted)
2050 .justify_between()
2051 .child(
2052 h_flex()
2053 .gap_1()
2054 .child(
2055 Label::new("Current:")
2056 .size(LabelSize::Small)
2057 .color(Color::Muted),
2058 )
2059 .child(MarkdownElement::new(
2060 entry.content.clone(),
2061 plan_label_markdown_style(&entry.status, window, cx),
2062 )),
2063 )
2064 .when(stats.pending > 0, |this| {
2065 this.child(
2066 Label::new(format!("{} left", stats.pending))
2067 .size(LabelSize::Small)
2068 .color(Color::Muted)
2069 .mr_1(),
2070 )
2071 })
2072 } else {
2073 let status_label = if stats.pending == 0 {
2074 "All Done".to_string()
2075 } else if stats.completed == 0 {
2076 format!("{} Tasks", plan.entries.len())
2077 } else {
2078 format!("{}/{}", stats.completed, plan.entries.len())
2079 };
2080
2081 h_flex()
2082 .w_full()
2083 .gap_1()
2084 .justify_between()
2085 .child(
2086 Label::new("Plan")
2087 .size(LabelSize::Small)
2088 .color(Color::Muted),
2089 )
2090 .child(
2091 Label::new(status_label)
2092 .size(LabelSize::Small)
2093 .color(Color::Muted)
2094 .mr_1(),
2095 )
2096 };
2097
2098 h_flex()
2099 .p_1()
2100 .justify_between()
2101 .when(self.plan_expanded, |this| {
2102 this.border_b_1().border_color(cx.theme().colors().border)
2103 })
2104 .child(
2105 h_flex()
2106 .id("plan_summary")
2107 .w_full()
2108 .gap_1()
2109 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
2110 .child(title)
2111 .on_click(cx.listener(|this, _, _, cx| {
2112 this.plan_expanded = !this.plan_expanded;
2113 cx.notify();
2114 })),
2115 )
2116 }
2117
2118 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2119 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
2120 let element = h_flex()
2121 .py_1()
2122 .px_2()
2123 .gap_2()
2124 .justify_between()
2125 .bg(cx.theme().colors().editor_background)
2126 .when(index < plan.entries.len() - 1, |parent| {
2127 parent.border_color(cx.theme().colors().border).border_b_1()
2128 })
2129 .child(
2130 h_flex()
2131 .id(("plan_entry", index))
2132 .gap_1p5()
2133 .max_w_full()
2134 .overflow_x_scroll()
2135 .text_xs()
2136 .text_color(cx.theme().colors().text_muted)
2137 .child(match entry.status {
2138 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
2139 .size(IconSize::Small)
2140 .color(Color::Muted)
2141 .into_any_element(),
2142 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
2143 .size(IconSize::Small)
2144 .color(Color::Accent)
2145 .with_animation(
2146 "running",
2147 Animation::new(Duration::from_secs(2)).repeat(),
2148 |icon, delta| {
2149 icon.transform(Transformation::rotate(percentage(delta)))
2150 },
2151 )
2152 .into_any_element(),
2153 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
2154 .size(IconSize::Small)
2155 .color(Color::Success)
2156 .into_any_element(),
2157 })
2158 .child(MarkdownElement::new(
2159 entry.content.clone(),
2160 plan_label_markdown_style(&entry.status, window, cx),
2161 )),
2162 );
2163
2164 Some(element)
2165 }))
2166 }
2167
2168 fn render_edits_summary(
2169 &self,
2170 action_log: &Entity<ActionLog>,
2171 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2172 expanded: bool,
2173 pending_edits: bool,
2174 window: &mut Window,
2175 cx: &Context<Self>,
2176 ) -> Div {
2177 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
2178
2179 let focus_handle = self.focus_handle(cx);
2180
2181 h_flex()
2182 .p_1()
2183 .justify_between()
2184 .when(expanded, |this| {
2185 this.border_b_1().border_color(cx.theme().colors().border)
2186 })
2187 .child(
2188 h_flex()
2189 .id("edits-container")
2190 .w_full()
2191 .gap_1()
2192 .child(Disclosure::new("edits-disclosure", expanded))
2193 .map(|this| {
2194 if pending_edits {
2195 this.child(
2196 Label::new(format!(
2197 "Editing {} {}…",
2198 changed_buffers.len(),
2199 if changed_buffers.len() == 1 {
2200 "file"
2201 } else {
2202 "files"
2203 }
2204 ))
2205 .color(Color::Muted)
2206 .size(LabelSize::Small)
2207 .with_animation(
2208 "edit-label",
2209 Animation::new(Duration::from_secs(2))
2210 .repeat()
2211 .with_easing(pulsating_between(0.3, 0.7)),
2212 |label, delta| label.alpha(delta),
2213 ),
2214 )
2215 } else {
2216 this.child(
2217 Label::new("Edits")
2218 .size(LabelSize::Small)
2219 .color(Color::Muted),
2220 )
2221 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
2222 .child(
2223 Label::new(format!(
2224 "{} {}",
2225 changed_buffers.len(),
2226 if changed_buffers.len() == 1 {
2227 "file"
2228 } else {
2229 "files"
2230 }
2231 ))
2232 .size(LabelSize::Small)
2233 .color(Color::Muted),
2234 )
2235 }
2236 })
2237 .on_click(cx.listener(|this, _, _, cx| {
2238 this.edits_expanded = !this.edits_expanded;
2239 cx.notify();
2240 })),
2241 )
2242 .child(
2243 h_flex()
2244 .gap_1()
2245 .child(
2246 IconButton::new("review-changes", IconName::ListTodo)
2247 .icon_size(IconSize::Small)
2248 .tooltip({
2249 let focus_handle = focus_handle.clone();
2250 move |window, cx| {
2251 Tooltip::for_action_in(
2252 "Review Changes",
2253 &OpenAgentDiff,
2254 &focus_handle,
2255 window,
2256 cx,
2257 )
2258 }
2259 })
2260 .on_click(cx.listener(|_, _, window, cx| {
2261 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
2262 })),
2263 )
2264 .child(Divider::vertical().color(DividerColor::Border))
2265 .child(
2266 Button::new("reject-all-changes", "Reject All")
2267 .label_size(LabelSize::Small)
2268 .disabled(pending_edits)
2269 .when(pending_edits, |this| {
2270 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2271 })
2272 .key_binding(
2273 KeyBinding::for_action_in(
2274 &RejectAll,
2275 &focus_handle.clone(),
2276 window,
2277 cx,
2278 )
2279 .map(|kb| kb.size(rems_from_px(10.))),
2280 )
2281 .on_click({
2282 let action_log = action_log.clone();
2283 cx.listener(move |_, _, _, cx| {
2284 action_log.update(cx, |action_log, cx| {
2285 action_log.reject_all_edits(cx).detach();
2286 })
2287 })
2288 }),
2289 )
2290 .child(
2291 Button::new("keep-all-changes", "Keep All")
2292 .label_size(LabelSize::Small)
2293 .disabled(pending_edits)
2294 .when(pending_edits, |this| {
2295 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2296 })
2297 .key_binding(
2298 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
2299 .map(|kb| kb.size(rems_from_px(10.))),
2300 )
2301 .on_click({
2302 let action_log = action_log.clone();
2303 cx.listener(move |_, _, _, cx| {
2304 action_log.update(cx, |action_log, cx| {
2305 action_log.keep_all_edits(cx);
2306 })
2307 })
2308 }),
2309 ),
2310 )
2311 }
2312
2313 fn render_edited_files(
2314 &self,
2315 action_log: &Entity<ActionLog>,
2316 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2317 pending_edits: bool,
2318 cx: &Context<Self>,
2319 ) -> Div {
2320 let editor_bg_color = cx.theme().colors().editor_background;
2321
2322 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
2323 |(index, (buffer, _diff))| {
2324 let file = buffer.read(cx).file()?;
2325 let path = file.path();
2326
2327 let file_path = path.parent().and_then(|parent| {
2328 let parent_str = parent.to_string_lossy();
2329
2330 if parent_str.is_empty() {
2331 None
2332 } else {
2333 Some(
2334 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
2335 .color(Color::Muted)
2336 .size(LabelSize::XSmall)
2337 .buffer_font(cx),
2338 )
2339 }
2340 });
2341
2342 let file_name = path.file_name().map(|name| {
2343 Label::new(name.to_string_lossy().to_string())
2344 .size(LabelSize::XSmall)
2345 .buffer_font(cx)
2346 });
2347
2348 let file_icon = FileIcons::get_icon(&path, cx)
2349 .map(Icon::from_path)
2350 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
2351 .unwrap_or_else(|| {
2352 Icon::new(IconName::File)
2353 .color(Color::Muted)
2354 .size(IconSize::Small)
2355 });
2356
2357 let overlay_gradient = linear_gradient(
2358 90.,
2359 linear_color_stop(editor_bg_color, 1.),
2360 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
2361 );
2362
2363 let element = h_flex()
2364 .group("edited-code")
2365 .id(("file-container", index))
2366 .relative()
2367 .py_1()
2368 .pl_2()
2369 .pr_1()
2370 .gap_2()
2371 .justify_between()
2372 .bg(editor_bg_color)
2373 .when(index < changed_buffers.len() - 1, |parent| {
2374 parent.border_color(cx.theme().colors().border).border_b_1()
2375 })
2376 .child(
2377 h_flex()
2378 .id(("file-name", index))
2379 .pr_8()
2380 .gap_1p5()
2381 .max_w_full()
2382 .overflow_x_scroll()
2383 .child(file_icon)
2384 .child(h_flex().gap_0p5().children(file_name).children(file_path))
2385 .on_click({
2386 let buffer = buffer.clone();
2387 cx.listener(move |this, _, window, cx| {
2388 this.open_edited_buffer(&buffer, window, cx);
2389 })
2390 }),
2391 )
2392 .child(
2393 h_flex()
2394 .gap_1()
2395 .visible_on_hover("edited-code")
2396 .child(
2397 Button::new("review", "Review")
2398 .label_size(LabelSize::Small)
2399 .on_click({
2400 let buffer = buffer.clone();
2401 cx.listener(move |this, _, window, cx| {
2402 this.open_edited_buffer(&buffer, window, cx);
2403 })
2404 }),
2405 )
2406 .child(Divider::vertical().color(DividerColor::BorderVariant))
2407 .child(
2408 Button::new("reject-file", "Reject")
2409 .label_size(LabelSize::Small)
2410 .disabled(pending_edits)
2411 .on_click({
2412 let buffer = buffer.clone();
2413 let action_log = action_log.clone();
2414 move |_, _, cx| {
2415 action_log.update(cx, |action_log, cx| {
2416 action_log
2417 .reject_edits_in_ranges(
2418 buffer.clone(),
2419 vec![Anchor::MIN..Anchor::MAX],
2420 cx,
2421 )
2422 .detach_and_log_err(cx);
2423 })
2424 }
2425 }),
2426 )
2427 .child(
2428 Button::new("keep-file", "Keep")
2429 .label_size(LabelSize::Small)
2430 .disabled(pending_edits)
2431 .on_click({
2432 let buffer = buffer.clone();
2433 let action_log = action_log.clone();
2434 move |_, _, cx| {
2435 action_log.update(cx, |action_log, cx| {
2436 action_log.keep_edits_in_range(
2437 buffer.clone(),
2438 Anchor::MIN..Anchor::MAX,
2439 cx,
2440 );
2441 })
2442 }
2443 }),
2444 ),
2445 )
2446 .child(
2447 div()
2448 .id("gradient-overlay")
2449 .absolute()
2450 .h_full()
2451 .w_12()
2452 .top_0()
2453 .bottom_0()
2454 .right(px(152.))
2455 .bg(overlay_gradient),
2456 );
2457
2458 Some(element)
2459 },
2460 ))
2461 }
2462
2463 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2464 let focus_handle = self.message_editor.focus_handle(cx);
2465 let editor_bg_color = cx.theme().colors().editor_background;
2466 let (expand_icon, expand_tooltip) = if self.editor_expanded {
2467 (IconName::Minimize, "Minimize Message Editor")
2468 } else {
2469 (IconName::Maximize, "Expand Message Editor")
2470 };
2471
2472 v_flex()
2473 .on_action(cx.listener(Self::expand_message_editor))
2474 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
2475 if let Some(profile_selector) = this.profile_selector.as_ref() {
2476 profile_selector.read(cx).menu_handle().toggle(window, cx);
2477 }
2478 }))
2479 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
2480 if let Some(model_selector) = this.model_selector.as_ref() {
2481 model_selector
2482 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
2483 }
2484 }))
2485 .p_2()
2486 .gap_2()
2487 .border_t_1()
2488 .border_color(cx.theme().colors().border)
2489 .bg(editor_bg_color)
2490 .when(self.editor_expanded, |this| {
2491 this.h(vh(0.8, window)).size_full().justify_between()
2492 })
2493 .child(
2494 v_flex()
2495 .relative()
2496 .size_full()
2497 .pt_1()
2498 .pr_2p5()
2499 .child(self.message_editor.clone())
2500 .child(
2501 h_flex()
2502 .absolute()
2503 .top_0()
2504 .right_0()
2505 .opacity(0.5)
2506 .hover(|this| this.opacity(1.0))
2507 .child(
2508 IconButton::new("toggle-height", expand_icon)
2509 .icon_size(IconSize::Small)
2510 .icon_color(Color::Muted)
2511 .tooltip({
2512 let focus_handle = focus_handle.clone();
2513 move |window, cx| {
2514 Tooltip::for_action_in(
2515 expand_tooltip,
2516 &ExpandMessageEditor,
2517 &focus_handle,
2518 window,
2519 cx,
2520 )
2521 }
2522 })
2523 .on_click(cx.listener(|_, _, window, cx| {
2524 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
2525 })),
2526 ),
2527 ),
2528 )
2529 .child(
2530 h_flex()
2531 .flex_none()
2532 .justify_between()
2533 .child(
2534 h_flex()
2535 .gap_1()
2536 .child(self.render_follow_toggle(cx))
2537 .children(self.render_burn_mode_toggle(cx)),
2538 )
2539 .child(
2540 h_flex()
2541 .gap_1()
2542 .children(self.profile_selector.clone())
2543 .children(self.model_selector.clone())
2544 .child(self.render_send_button(cx)),
2545 ),
2546 )
2547 .into_any()
2548 }
2549
2550 pub(crate) fn as_native_connection(
2551 &self,
2552 cx: &App,
2553 ) -> Option<Rc<agent2::NativeAgentConnection>> {
2554 let acp_thread = self.thread()?.read(cx);
2555 acp_thread.connection().clone().downcast()
2556 }
2557
2558 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
2559 let acp_thread = self.thread()?.read(cx);
2560 self.as_native_connection(cx)?
2561 .thread(acp_thread.session_id(), cx)
2562 }
2563
2564 fn toggle_burn_mode(
2565 &mut self,
2566 _: &ToggleBurnMode,
2567 _window: &mut Window,
2568 cx: &mut Context<Self>,
2569 ) {
2570 let Some(thread) = self.as_native_thread(cx) else {
2571 return;
2572 };
2573
2574 thread.update(cx, |thread, cx| {
2575 let current_mode = thread.completion_mode();
2576 thread.set_completion_mode(
2577 match current_mode {
2578 CompletionMode::Burn => CompletionMode::Normal,
2579 CompletionMode::Normal => CompletionMode::Burn,
2580 },
2581 cx,
2582 );
2583 });
2584 }
2585
2586 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
2587 let thread = self.as_native_thread(cx)?.read(cx);
2588
2589 if thread
2590 .model()
2591 .map_or(true, |model| !model.supports_burn_mode())
2592 {
2593 return None;
2594 }
2595
2596 let active_completion_mode = thread.completion_mode();
2597 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
2598 let icon = if burn_mode_enabled {
2599 IconName::ZedBurnModeOn
2600 } else {
2601 IconName::ZedBurnMode
2602 };
2603
2604 Some(
2605 IconButton::new("burn-mode", icon)
2606 .icon_size(IconSize::Small)
2607 .icon_color(Color::Muted)
2608 .toggle_state(burn_mode_enabled)
2609 .selected_icon_color(Color::Error)
2610 .on_click(cx.listener(|this, _event, window, cx| {
2611 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
2612 }))
2613 .tooltip(move |_window, cx| {
2614 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
2615 .into()
2616 })
2617 .into_any_element(),
2618 )
2619 }
2620
2621 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
2622 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
2623 let is_generating = self.thread().map_or(false, |thread| {
2624 thread.read(cx).status() != ThreadStatus::Idle
2625 });
2626
2627 if is_generating && is_editor_empty {
2628 IconButton::new("stop-generation", IconName::Stop)
2629 .icon_color(Color::Error)
2630 .style(ButtonStyle::Tinted(ui::TintColor::Error))
2631 .tooltip(move |window, cx| {
2632 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
2633 })
2634 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
2635 .into_any_element()
2636 } else {
2637 let send_btn_tooltip = if is_editor_empty && !is_generating {
2638 "Type to Send"
2639 } else if is_generating {
2640 "Stop and Send Message"
2641 } else {
2642 "Send"
2643 };
2644
2645 IconButton::new("send-message", IconName::Send)
2646 .style(ButtonStyle::Filled)
2647 .map(|this| {
2648 if is_editor_empty && !is_generating {
2649 this.disabled(true).icon_color(Color::Muted)
2650 } else {
2651 this.icon_color(Color::Accent)
2652 }
2653 })
2654 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
2655 .on_click(cx.listener(|this, _, window, cx| {
2656 this.send(window, cx);
2657 }))
2658 .into_any_element()
2659 }
2660 }
2661
2662 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2663 let following = self
2664 .workspace
2665 .read_with(cx, |workspace, _| {
2666 workspace.is_being_followed(CollaboratorId::Agent)
2667 })
2668 .unwrap_or(false);
2669
2670 IconButton::new("follow-agent", IconName::Crosshair)
2671 .icon_size(IconSize::Small)
2672 .icon_color(Color::Muted)
2673 .toggle_state(following)
2674 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
2675 .tooltip(move |window, cx| {
2676 if following {
2677 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
2678 } else {
2679 Tooltip::with_meta(
2680 "Follow Agent",
2681 Some(&Follow),
2682 "Track the agent's location as it reads and edits files.",
2683 window,
2684 cx,
2685 )
2686 }
2687 })
2688 .on_click(cx.listener(move |this, _, window, cx| {
2689 this.workspace
2690 .update(cx, |workspace, cx| {
2691 if following {
2692 workspace.unfollow(CollaboratorId::Agent, window, cx);
2693 } else {
2694 workspace.follow(CollaboratorId::Agent, window, cx);
2695 }
2696 })
2697 .ok();
2698 }))
2699 }
2700
2701 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2702 let workspace = self.workspace.clone();
2703 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2704 Self::open_link(text, &workspace, window, cx);
2705 })
2706 }
2707
2708 fn open_link(
2709 url: SharedString,
2710 workspace: &WeakEntity<Workspace>,
2711 window: &mut Window,
2712 cx: &mut App,
2713 ) {
2714 let Some(workspace) = workspace.upgrade() else {
2715 cx.open_url(&url);
2716 return;
2717 };
2718
2719 if let Some(mention) = MentionUri::parse(&url).log_err() {
2720 workspace.update(cx, |workspace, cx| match mention {
2721 MentionUri::File { abs_path, .. } => {
2722 let project = workspace.project();
2723 let Some((path, entry)) = project.update(cx, |project, cx| {
2724 let path = project.find_project_path(abs_path, cx)?;
2725 let entry = project.entry_for_path(&path, cx)?;
2726 Some((path, entry))
2727 }) else {
2728 return;
2729 };
2730
2731 if entry.is_dir() {
2732 project.update(cx, |_, cx| {
2733 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2734 });
2735 } else {
2736 workspace
2737 .open_path(path, None, true, window, cx)
2738 .detach_and_log_err(cx);
2739 }
2740 }
2741 MentionUri::Symbol {
2742 path, line_range, ..
2743 }
2744 | MentionUri::Selection { path, line_range } => {
2745 let project = workspace.project();
2746 let Some((path, _)) = project.update(cx, |project, cx| {
2747 let path = project.find_project_path(path, cx)?;
2748 let entry = project.entry_for_path(&path, cx)?;
2749 Some((path, entry))
2750 }) else {
2751 return;
2752 };
2753
2754 let item = workspace.open_path(path, None, true, window, cx);
2755 window
2756 .spawn(cx, async move |cx| {
2757 let Some(editor) = item.await?.downcast::<Editor>() else {
2758 return Ok(());
2759 };
2760 let range =
2761 Point::new(line_range.start, 0)..Point::new(line_range.start, 0);
2762 editor
2763 .update_in(cx, |editor, window, cx| {
2764 editor.change_selections(
2765 SelectionEffects::scroll(Autoscroll::center()),
2766 window,
2767 cx,
2768 |s| s.select_ranges(vec![range]),
2769 );
2770 })
2771 .ok();
2772 anyhow::Ok(())
2773 })
2774 .detach_and_log_err(cx);
2775 }
2776 MentionUri::Thread { id, .. } => {
2777 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2778 panel.update(cx, |panel, cx| {
2779 panel
2780 .open_thread_by_id(&id, window, cx)
2781 .detach_and_log_err(cx)
2782 });
2783 }
2784 }
2785 MentionUri::TextThread { path, .. } => {
2786 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2787 panel.update(cx, |panel, cx| {
2788 panel
2789 .open_saved_prompt_editor(path.as_path().into(), window, cx)
2790 .detach_and_log_err(cx);
2791 });
2792 }
2793 }
2794 MentionUri::Rule { id, .. } => {
2795 let PromptId::User { uuid } = id else {
2796 return;
2797 };
2798 window.dispatch_action(
2799 Box::new(OpenRulesLibrary {
2800 prompt_to_select: Some(uuid.0),
2801 }),
2802 cx,
2803 )
2804 }
2805 MentionUri::Fetch { url } => {
2806 cx.open_url(url.as_str());
2807 }
2808 })
2809 } else {
2810 cx.open_url(&url);
2811 }
2812 }
2813
2814 fn open_tool_call_location(
2815 &self,
2816 entry_ix: usize,
2817 location_ix: usize,
2818 window: &mut Window,
2819 cx: &mut Context<Self>,
2820 ) -> Option<()> {
2821 let (tool_call_location, agent_location) = self
2822 .thread()?
2823 .read(cx)
2824 .entries()
2825 .get(entry_ix)?
2826 .location(location_ix)?;
2827
2828 let project_path = self
2829 .project
2830 .read(cx)
2831 .find_project_path(&tool_call_location.path, cx)?;
2832
2833 let open_task = self
2834 .workspace
2835 .update(cx, |workspace, cx| {
2836 workspace.open_path(project_path, None, true, window, cx)
2837 })
2838 .log_err()?;
2839 window
2840 .spawn(cx, async move |cx| {
2841 let item = open_task.await?;
2842
2843 let Some(active_editor) = item.downcast::<Editor>() else {
2844 return anyhow::Ok(());
2845 };
2846
2847 active_editor.update_in(cx, |editor, window, cx| {
2848 let multibuffer = editor.buffer().read(cx);
2849 let buffer = multibuffer.as_singleton();
2850 if agent_location.buffer.upgrade() == buffer {
2851 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
2852 let anchor = editor::Anchor::in_buffer(
2853 excerpt_id.unwrap(),
2854 buffer.unwrap().read(cx).remote_id(),
2855 agent_location.position,
2856 );
2857 editor.change_selections(Default::default(), window, cx, |selections| {
2858 selections.select_anchor_ranges([anchor..anchor]);
2859 })
2860 } else {
2861 let row = tool_call_location.line.unwrap_or_default();
2862 editor.change_selections(Default::default(), window, cx, |selections| {
2863 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
2864 })
2865 }
2866 })?;
2867
2868 anyhow::Ok(())
2869 })
2870 .detach_and_log_err(cx);
2871
2872 None
2873 }
2874
2875 pub fn open_thread_as_markdown(
2876 &self,
2877 workspace: Entity<Workspace>,
2878 window: &mut Window,
2879 cx: &mut App,
2880 ) -> Task<anyhow::Result<()>> {
2881 let markdown_language_task = workspace
2882 .read(cx)
2883 .app_state()
2884 .languages
2885 .language_for_name("Markdown");
2886
2887 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
2888 let thread = thread.read(cx);
2889 (thread.title().to_string(), thread.to_markdown(cx))
2890 } else {
2891 return Task::ready(Ok(()));
2892 };
2893
2894 window.spawn(cx, async move |cx| {
2895 let markdown_language = markdown_language_task.await?;
2896
2897 workspace.update_in(cx, |workspace, window, cx| {
2898 let project = workspace.project().clone();
2899
2900 if !project.read(cx).is_local() {
2901 bail!("failed to open active thread as markdown in remote project");
2902 }
2903
2904 let buffer = project.update(cx, |project, cx| {
2905 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2906 });
2907 let buffer = cx.new(|cx| {
2908 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2909 });
2910
2911 workspace.add_item_to_active_pane(
2912 Box::new(cx.new(|cx| {
2913 let mut editor =
2914 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2915 editor.set_breadcrumb_header(thread_summary);
2916 editor
2917 })),
2918 None,
2919 true,
2920 window,
2921 cx,
2922 );
2923
2924 anyhow::Ok(())
2925 })??;
2926 anyhow::Ok(())
2927 })
2928 }
2929
2930 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
2931 self.list_state.scroll_to(ListOffset::default());
2932 cx.notify();
2933 }
2934
2935 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
2936 if let Some(thread) = self.thread() {
2937 let entry_count = thread.read(cx).entries().len();
2938 self.list_state.reset(entry_count);
2939 cx.notify();
2940 }
2941 }
2942
2943 fn notify_with_sound(
2944 &mut self,
2945 caption: impl Into<SharedString>,
2946 icon: IconName,
2947 window: &mut Window,
2948 cx: &mut Context<Self>,
2949 ) {
2950 self.play_notification_sound(window, cx);
2951 self.show_notification(caption, icon, window, cx);
2952 }
2953
2954 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
2955 let settings = AgentSettings::get_global(cx);
2956 if settings.play_sound_when_agent_done && !window.is_window_active() {
2957 Audio::play_sound(Sound::AgentDone, cx);
2958 }
2959 }
2960
2961 fn show_notification(
2962 &mut self,
2963 caption: impl Into<SharedString>,
2964 icon: IconName,
2965 window: &mut Window,
2966 cx: &mut Context<Self>,
2967 ) {
2968 if window.is_window_active() || !self.notifications.is_empty() {
2969 return;
2970 }
2971
2972 let title = self.title(cx);
2973
2974 match AgentSettings::get_global(cx).notify_when_agent_waiting {
2975 NotifyWhenAgentWaiting::PrimaryScreen => {
2976 if let Some(primary) = cx.primary_display() {
2977 self.pop_up(icon, caption.into(), title, window, primary, cx);
2978 }
2979 }
2980 NotifyWhenAgentWaiting::AllScreens => {
2981 let caption = caption.into();
2982 for screen in cx.displays() {
2983 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
2984 }
2985 }
2986 NotifyWhenAgentWaiting::Never => {
2987 // Don't show anything
2988 }
2989 }
2990 }
2991
2992 fn pop_up(
2993 &mut self,
2994 icon: IconName,
2995 caption: SharedString,
2996 title: SharedString,
2997 window: &mut Window,
2998 screen: Rc<dyn PlatformDisplay>,
2999 cx: &mut Context<Self>,
3000 ) {
3001 let options = AgentNotification::window_options(screen, cx);
3002
3003 let project_name = self.workspace.upgrade().and_then(|workspace| {
3004 workspace
3005 .read(cx)
3006 .project()
3007 .read(cx)
3008 .visible_worktrees(cx)
3009 .next()
3010 .map(|worktree| worktree.read(cx).root_name().to_string())
3011 });
3012
3013 if let Some(screen_window) = cx
3014 .open_window(options, |_, cx| {
3015 cx.new(|_| {
3016 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
3017 })
3018 })
3019 .log_err()
3020 {
3021 if let Some(pop_up) = screen_window.entity(cx).log_err() {
3022 self.notification_subscriptions
3023 .entry(screen_window)
3024 .or_insert_with(Vec::new)
3025 .push(cx.subscribe_in(&pop_up, window, {
3026 |this, _, event, window, cx| match event {
3027 AgentNotificationEvent::Accepted => {
3028 let handle = window.window_handle();
3029 cx.activate(true);
3030
3031 let workspace_handle = this.workspace.clone();
3032
3033 // If there are multiple Zed windows, activate the correct one.
3034 cx.defer(move |cx| {
3035 handle
3036 .update(cx, |_view, window, _cx| {
3037 window.activate_window();
3038
3039 if let Some(workspace) = workspace_handle.upgrade() {
3040 workspace.update(_cx, |workspace, cx| {
3041 workspace.focus_panel::<AgentPanel>(window, cx);
3042 });
3043 }
3044 })
3045 .log_err();
3046 });
3047
3048 this.dismiss_notifications(cx);
3049 }
3050 AgentNotificationEvent::Dismissed => {
3051 this.dismiss_notifications(cx);
3052 }
3053 }
3054 }));
3055
3056 self.notifications.push(screen_window);
3057
3058 // If the user manually refocuses the original window, dismiss the popup.
3059 self.notification_subscriptions
3060 .entry(screen_window)
3061 .or_insert_with(Vec::new)
3062 .push({
3063 let pop_up_weak = pop_up.downgrade();
3064
3065 cx.observe_window_activation(window, move |_, window, cx| {
3066 if window.is_window_active() {
3067 if let Some(pop_up) = pop_up_weak.upgrade() {
3068 pop_up.update(cx, |_, cx| {
3069 cx.emit(AgentNotificationEvent::Dismissed);
3070 });
3071 }
3072 }
3073 })
3074 });
3075 }
3076 }
3077 }
3078
3079 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
3080 for window in self.notifications.drain(..) {
3081 window
3082 .update(cx, |_, window, _| {
3083 window.remove_window();
3084 })
3085 .ok();
3086
3087 self.notification_subscriptions.remove(&window);
3088 }
3089 }
3090
3091 fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement {
3092 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
3093 .shape(ui::IconButtonShape::Square)
3094 .icon_size(IconSize::Small)
3095 .icon_color(Color::Ignored)
3096 .tooltip(Tooltip::text("Open Thread as Markdown"))
3097 .on_click(cx.listener(move |this, _, window, cx| {
3098 if let Some(workspace) = this.workspace.upgrade() {
3099 this.open_thread_as_markdown(workspace, window, cx)
3100 .detach_and_log_err(cx);
3101 }
3102 }));
3103
3104 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
3105 .shape(ui::IconButtonShape::Square)
3106 .icon_size(IconSize::Small)
3107 .icon_color(Color::Ignored)
3108 .tooltip(Tooltip::text("Scroll To Top"))
3109 .on_click(cx.listener(move |this, _, _, cx| {
3110 this.scroll_to_top(cx);
3111 }));
3112
3113 h_flex()
3114 .w_full()
3115 .mr_1()
3116 .pb_2()
3117 .px(RESPONSE_PADDING_X)
3118 .opacity(0.4)
3119 .hover(|style| style.opacity(1.))
3120 .flex_wrap()
3121 .justify_end()
3122 .child(open_as_markdown)
3123 .child(scroll_to_top)
3124 }
3125
3126 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
3127 div()
3128 .id("acp-thread-scrollbar")
3129 .occlude()
3130 .on_mouse_move(cx.listener(|_, _, _, cx| {
3131 cx.notify();
3132 cx.stop_propagation()
3133 }))
3134 .on_hover(|_, _, cx| {
3135 cx.stop_propagation();
3136 })
3137 .on_any_mouse_down(|_, _, cx| {
3138 cx.stop_propagation();
3139 })
3140 .on_mouse_up(
3141 MouseButton::Left,
3142 cx.listener(|_, _, _, cx| {
3143 cx.stop_propagation();
3144 }),
3145 )
3146 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3147 cx.notify();
3148 }))
3149 .h_full()
3150 .absolute()
3151 .right_1()
3152 .top_1()
3153 .bottom_0()
3154 .w(px(12.))
3155 .cursor_default()
3156 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
3157 }
3158
3159 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3160 self.entry_view_state.update(cx, |entry_view_state, cx| {
3161 entry_view_state.settings_changed(cx);
3162 });
3163 }
3164
3165 pub(crate) fn insert_dragged_files(
3166 &self,
3167 paths: Vec<project::ProjectPath>,
3168 added_worktrees: Vec<Entity<project::Worktree>>,
3169 window: &mut Window,
3170 cx: &mut Context<Self>,
3171 ) {
3172 self.message_editor.update(cx, |message_editor, cx| {
3173 message_editor.insert_dragged_files(paths, window, cx);
3174 drop(added_worktrees);
3175 })
3176 }
3177
3178 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<'_, Self>) -> Option<Div> {
3179 let content = match self.thread_error.as_ref()? {
3180 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
3181 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
3182 ThreadError::ModelRequestLimitReached(plan) => {
3183 self.render_model_request_limit_reached_error(*plan, cx)
3184 }
3185 ThreadError::ToolUseLimitReached => {
3186 self.render_tool_use_limit_reached_error(window, cx)?
3187 }
3188 };
3189
3190 Some(
3191 div()
3192 .border_t_1()
3193 .border_color(cx.theme().colors().border)
3194 .child(content),
3195 )
3196 }
3197
3198 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
3199 let icon = Icon::new(IconName::XCircle)
3200 .size(IconSize::Small)
3201 .color(Color::Error);
3202
3203 Callout::new()
3204 .icon(icon)
3205 .title("Error")
3206 .description(error.clone())
3207 .secondary_action(self.create_copy_button(error.to_string()))
3208 .primary_action(self.dismiss_error_button(cx))
3209 .bg_color(self.error_callout_bg(cx))
3210 }
3211
3212 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
3213 const ERROR_MESSAGE: &str =
3214 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
3215
3216 let icon = Icon::new(IconName::XCircle)
3217 .size(IconSize::Small)
3218 .color(Color::Error);
3219
3220 Callout::new()
3221 .icon(icon)
3222 .title("Free Usage Exceeded")
3223 .description(ERROR_MESSAGE)
3224 .tertiary_action(self.upgrade_button(cx))
3225 .secondary_action(self.create_copy_button(ERROR_MESSAGE))
3226 .primary_action(self.dismiss_error_button(cx))
3227 .bg_color(self.error_callout_bg(cx))
3228 }
3229
3230 fn render_model_request_limit_reached_error(
3231 &self,
3232 plan: cloud_llm_client::Plan,
3233 cx: &mut Context<Self>,
3234 ) -> Callout {
3235 let error_message = match plan {
3236 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
3237 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
3238 "Upgrade to Zed Pro for more prompts."
3239 }
3240 };
3241
3242 let icon = Icon::new(IconName::XCircle)
3243 .size(IconSize::Small)
3244 .color(Color::Error);
3245
3246 Callout::new()
3247 .icon(icon)
3248 .title("Model Prompt Limit Reached")
3249 .description(error_message)
3250 .tertiary_action(self.upgrade_button(cx))
3251 .secondary_action(self.create_copy_button(error_message))
3252 .primary_action(self.dismiss_error_button(cx))
3253 .bg_color(self.error_callout_bg(cx))
3254 }
3255
3256 fn render_tool_use_limit_reached_error(
3257 &self,
3258 window: &mut Window,
3259 cx: &mut Context<Self>,
3260 ) -> Option<Callout> {
3261 let thread = self.as_native_thread(cx)?;
3262 let supports_burn_mode = thread
3263 .read(cx)
3264 .model()
3265 .map_or(false, |model| model.supports_burn_mode());
3266
3267 let focus_handle = self.focus_handle(cx);
3268
3269 let icon = Icon::new(IconName::Info)
3270 .size(IconSize::Small)
3271 .color(Color::Info);
3272
3273 Some(
3274 Callout::new()
3275 .icon(icon)
3276 .title("Consecutive tool use limit reached.")
3277 .when(supports_burn_mode, |this| {
3278 this.secondary_action(
3279 Button::new("continue-burn-mode", "Continue with Burn Mode")
3280 .style(ButtonStyle::Filled)
3281 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3282 .layer(ElevationIndex::ModalSurface)
3283 .label_size(LabelSize::Small)
3284 .key_binding(
3285 KeyBinding::for_action_in(
3286 &ContinueWithBurnMode,
3287 &focus_handle,
3288 window,
3289 cx,
3290 )
3291 .map(|kb| kb.size(rems_from_px(10.))),
3292 )
3293 .tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use."))
3294 .on_click({
3295 cx.listener(move |this, _, _window, cx| {
3296 thread.update(cx, |thread, cx| {
3297 thread.set_completion_mode(CompletionMode::Burn, cx);
3298 });
3299 this.resume_chat(cx);
3300 })
3301 }),
3302 )
3303 })
3304 .primary_action(
3305 Button::new("continue-conversation", "Continue")
3306 .layer(ElevationIndex::ModalSurface)
3307 .label_size(LabelSize::Small)
3308 .key_binding(
3309 KeyBinding::for_action_in(&ContinueThread, &focus_handle, window, cx)
3310 .map(|kb| kb.size(rems_from_px(10.))),
3311 )
3312 .on_click(cx.listener(|this, _, _window, cx| {
3313 this.resume_chat(cx);
3314 })),
3315 ),
3316 )
3317 }
3318
3319 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
3320 let message = message.into();
3321
3322 IconButton::new("copy", IconName::Copy)
3323 .icon_size(IconSize::Small)
3324 .icon_color(Color::Muted)
3325 .tooltip(Tooltip::text("Copy Error Message"))
3326 .on_click(move |_, _, cx| {
3327 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
3328 })
3329 }
3330
3331 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3332 IconButton::new("dismiss", IconName::Close)
3333 .icon_size(IconSize::Small)
3334 .icon_color(Color::Muted)
3335 .tooltip(Tooltip::text("Dismiss Error"))
3336 .on_click(cx.listener({
3337 move |this, _, _, cx| {
3338 this.clear_thread_error(cx);
3339 cx.notify();
3340 }
3341 }))
3342 }
3343
3344 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3345 Button::new("upgrade", "Upgrade")
3346 .label_size(LabelSize::Small)
3347 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3348 .on_click(cx.listener({
3349 move |this, _, _, cx| {
3350 this.clear_thread_error(cx);
3351 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
3352 }
3353 }))
3354 }
3355
3356 fn error_callout_bg(&self, cx: &Context<Self>) -> Hsla {
3357 cx.theme().status().error.opacity(0.08)
3358 }
3359}
3360
3361impl Focusable for AcpThreadView {
3362 fn focus_handle(&self, cx: &App) -> FocusHandle {
3363 self.message_editor.focus_handle(cx)
3364 }
3365}
3366
3367impl Render for AcpThreadView {
3368 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3369 let has_messages = self.list_state.item_count() > 0;
3370
3371 v_flex()
3372 .size_full()
3373 .key_context("AcpThread")
3374 .on_action(cx.listener(Self::open_agent_diff))
3375 .on_action(cx.listener(Self::toggle_burn_mode))
3376 .bg(cx.theme().colors().panel_background)
3377 .child(match &self.thread_state {
3378 ThreadState::Unauthenticated { connection } => v_flex()
3379 .p_2()
3380 .flex_1()
3381 .items_center()
3382 .justify_center()
3383 .child(self.render_pending_auth_state())
3384 .child(h_flex().mt_1p5().justify_center().children(
3385 connection.auth_methods().into_iter().map(|method| {
3386 Button::new(
3387 SharedString::from(method.id.0.clone()),
3388 method.name.clone(),
3389 )
3390 .on_click({
3391 let method_id = method.id.clone();
3392 cx.listener(move |this, _, window, cx| {
3393 this.authenticate(method_id.clone(), window, cx)
3394 })
3395 })
3396 }),
3397 )),
3398 ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
3399 ThreadState::LoadError(e) => v_flex()
3400 .p_2()
3401 .flex_1()
3402 .items_center()
3403 .justify_center()
3404 .child(self.render_load_error(e, cx)),
3405 ThreadState::ServerExited { status } => v_flex()
3406 .p_2()
3407 .flex_1()
3408 .items_center()
3409 .justify_center()
3410 .child(self.render_server_exited(*status, cx)),
3411 ThreadState::Ready { thread, .. } => {
3412 let thread_clone = thread.clone();
3413
3414 v_flex().flex_1().map(|this| {
3415 if has_messages {
3416 this.child(
3417 list(
3418 self.list_state.clone(),
3419 cx.processor(|this, index: usize, window, cx| {
3420 let Some((entry, len)) = this.thread().and_then(|thread| {
3421 let entries = &thread.read(cx).entries();
3422 Some((entries.get(index)?, entries.len()))
3423 }) else {
3424 return Empty.into_any();
3425 };
3426 this.render_entry(index, len, entry, window, cx)
3427 }),
3428 )
3429 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
3430 .flex_grow()
3431 .into_any(),
3432 )
3433 .child(self.render_vertical_scrollbar(cx))
3434 .children(
3435 match thread_clone.read(cx).status() {
3436 ThreadStatus::Idle
3437 | ThreadStatus::WaitingForToolConfirmation => None,
3438 ThreadStatus::Generating => div()
3439 .px_5()
3440 .py_2()
3441 .child(LoadingLabel::new("").size(LabelSize::Small))
3442 .into(),
3443 },
3444 )
3445 } else {
3446 this.child(self.render_empty_state(cx))
3447 }
3448 })
3449 }
3450 })
3451 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
3452 // above so that the scrollbar doesn't render behind it. The current setup allows
3453 // the scrollbar to stop exactly at the activity bar start.
3454 .when(has_messages, |this| match &self.thread_state {
3455 ThreadState::Ready { thread, .. } => {
3456 this.children(self.render_activity_bar(thread, window, cx))
3457 }
3458 _ => this,
3459 })
3460 .children(self.render_thread_error(window, cx))
3461 .child(self.render_message_editor(window, cx))
3462 }
3463}
3464
3465fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
3466 let theme_settings = ThemeSettings::get_global(cx);
3467 let colors = cx.theme().colors();
3468
3469 let buffer_font_size = TextSize::Small.rems(cx);
3470
3471 let mut text_style = window.text_style();
3472 let line_height = buffer_font_size * 1.75;
3473
3474 let font_family = if buffer_font {
3475 theme_settings.buffer_font.family.clone()
3476 } else {
3477 theme_settings.ui_font.family.clone()
3478 };
3479
3480 let font_size = if buffer_font {
3481 TextSize::Small.rems(cx)
3482 } else {
3483 TextSize::Default.rems(cx)
3484 };
3485
3486 text_style.refine(&TextStyleRefinement {
3487 font_family: Some(font_family),
3488 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
3489 font_features: Some(theme_settings.ui_font.features.clone()),
3490 font_size: Some(font_size.into()),
3491 line_height: Some(line_height.into()),
3492 color: Some(cx.theme().colors().text),
3493 ..Default::default()
3494 });
3495
3496 MarkdownStyle {
3497 base_text_style: text_style.clone(),
3498 syntax: cx.theme().syntax().clone(),
3499 selection_background_color: cx.theme().colors().element_selection_background,
3500 code_block_overflow_x_scroll: true,
3501 table_overflow_x_scroll: true,
3502 heading_level_styles: Some(HeadingLevelStyles {
3503 h1: Some(TextStyleRefinement {
3504 font_size: Some(rems(1.15).into()),
3505 ..Default::default()
3506 }),
3507 h2: Some(TextStyleRefinement {
3508 font_size: Some(rems(1.1).into()),
3509 ..Default::default()
3510 }),
3511 h3: Some(TextStyleRefinement {
3512 font_size: Some(rems(1.05).into()),
3513 ..Default::default()
3514 }),
3515 h4: Some(TextStyleRefinement {
3516 font_size: Some(rems(1.).into()),
3517 ..Default::default()
3518 }),
3519 h5: Some(TextStyleRefinement {
3520 font_size: Some(rems(0.95).into()),
3521 ..Default::default()
3522 }),
3523 h6: Some(TextStyleRefinement {
3524 font_size: Some(rems(0.875).into()),
3525 ..Default::default()
3526 }),
3527 }),
3528 code_block: StyleRefinement {
3529 padding: EdgesRefinement {
3530 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3531 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3532 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3533 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3534 },
3535 margin: EdgesRefinement {
3536 top: Some(Length::Definite(Pixels(8.).into())),
3537 left: Some(Length::Definite(Pixels(0.).into())),
3538 right: Some(Length::Definite(Pixels(0.).into())),
3539 bottom: Some(Length::Definite(Pixels(12.).into())),
3540 },
3541 border_style: Some(BorderStyle::Solid),
3542 border_widths: EdgesRefinement {
3543 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
3544 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
3545 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
3546 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
3547 },
3548 border_color: Some(colors.border_variant),
3549 background: Some(colors.editor_background.into()),
3550 text: Some(TextStyleRefinement {
3551 font_family: Some(theme_settings.buffer_font.family.clone()),
3552 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3553 font_features: Some(theme_settings.buffer_font.features.clone()),
3554 font_size: Some(buffer_font_size.into()),
3555 ..Default::default()
3556 }),
3557 ..Default::default()
3558 },
3559 inline_code: TextStyleRefinement {
3560 font_family: Some(theme_settings.buffer_font.family.clone()),
3561 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3562 font_features: Some(theme_settings.buffer_font.features.clone()),
3563 font_size: Some(buffer_font_size.into()),
3564 background_color: Some(colors.editor_foreground.opacity(0.08)),
3565 ..Default::default()
3566 },
3567 link: TextStyleRefinement {
3568 background_color: Some(colors.editor_foreground.opacity(0.025)),
3569 underline: Some(UnderlineStyle {
3570 color: Some(colors.text_accent.opacity(0.5)),
3571 thickness: px(1.),
3572 ..Default::default()
3573 }),
3574 ..Default::default()
3575 },
3576 ..Default::default()
3577 }
3578}
3579
3580fn plan_label_markdown_style(
3581 status: &acp::PlanEntryStatus,
3582 window: &Window,
3583 cx: &App,
3584) -> MarkdownStyle {
3585 let default_md_style = default_markdown_style(false, window, cx);
3586
3587 MarkdownStyle {
3588 base_text_style: TextStyle {
3589 color: cx.theme().colors().text_muted,
3590 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
3591 Some(gpui::StrikethroughStyle {
3592 thickness: px(1.),
3593 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
3594 })
3595 } else {
3596 None
3597 },
3598 ..default_md_style.base_text_style
3599 },
3600 ..default_md_style
3601 }
3602}
3603
3604fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
3605 let default_md_style = default_markdown_style(true, window, cx);
3606
3607 MarkdownStyle {
3608 base_text_style: TextStyle {
3609 ..default_md_style.base_text_style
3610 },
3611 selection_background_color: cx.theme().colors().element_selection_background,
3612 ..Default::default()
3613 }
3614}
3615
3616#[cfg(test)]
3617pub(crate) mod tests {
3618 use acp_thread::{AgentServerName, StubAgentConnection};
3619 use agent::{TextThreadStore, ThreadStore};
3620 use agent_client_protocol::SessionId;
3621 use editor::EditorSettings;
3622 use fs::FakeFs;
3623 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
3624 use project::Project;
3625 use serde_json::json;
3626 use settings::SettingsStore;
3627 use std::any::Any;
3628 use std::path::Path;
3629 use workspace::Item;
3630
3631 use super::*;
3632
3633 #[gpui::test]
3634 async fn test_drop(cx: &mut TestAppContext) {
3635 init_test(cx);
3636
3637 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3638 let weak_view = thread_view.downgrade();
3639 drop(thread_view);
3640 assert!(!weak_view.is_upgradable());
3641 }
3642
3643 #[gpui::test]
3644 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3645 init_test(cx);
3646
3647 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3648
3649 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3650 message_editor.update_in(cx, |editor, window, cx| {
3651 editor.set_text("Hello", window, cx);
3652 });
3653
3654 cx.deactivate_window();
3655
3656 thread_view.update_in(cx, |thread_view, window, cx| {
3657 thread_view.send(window, cx);
3658 });
3659
3660 cx.run_until_parked();
3661
3662 assert!(
3663 cx.windows()
3664 .iter()
3665 .any(|window| window.downcast::<AgentNotification>().is_some())
3666 );
3667 }
3668
3669 #[gpui::test]
3670 async fn test_notification_for_error(cx: &mut TestAppContext) {
3671 init_test(cx);
3672
3673 let (thread_view, cx) =
3674 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
3675
3676 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3677 message_editor.update_in(cx, |editor, window, cx| {
3678 editor.set_text("Hello", window, cx);
3679 });
3680
3681 cx.deactivate_window();
3682
3683 thread_view.update_in(cx, |thread_view, window, cx| {
3684 thread_view.send(window, cx);
3685 });
3686
3687 cx.run_until_parked();
3688
3689 assert!(
3690 cx.windows()
3691 .iter()
3692 .any(|window| window.downcast::<AgentNotification>().is_some())
3693 );
3694 }
3695
3696 #[gpui::test]
3697 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3698 init_test(cx);
3699
3700 let tool_call_id = acp::ToolCallId("1".into());
3701 let tool_call = acp::ToolCall {
3702 id: tool_call_id.clone(),
3703 title: "Label".into(),
3704 kind: acp::ToolKind::Edit,
3705 status: acp::ToolCallStatus::Pending,
3706 content: vec!["hi".into()],
3707 locations: vec![],
3708 raw_input: None,
3709 raw_output: None,
3710 };
3711 let connection =
3712 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3713 tool_call_id,
3714 vec![acp::PermissionOption {
3715 id: acp::PermissionOptionId("1".into()),
3716 name: "Allow".into(),
3717 kind: acp::PermissionOptionKind::AllowOnce,
3718 }],
3719 )]));
3720
3721 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3722
3723 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3724
3725 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3726 message_editor.update_in(cx, |editor, window, cx| {
3727 editor.set_text("Hello", window, cx);
3728 });
3729
3730 cx.deactivate_window();
3731
3732 thread_view.update_in(cx, |thread_view, window, cx| {
3733 thread_view.send(window, cx);
3734 });
3735
3736 cx.run_until_parked();
3737
3738 assert!(
3739 cx.windows()
3740 .iter()
3741 .any(|window| window.downcast::<AgentNotification>().is_some())
3742 );
3743 }
3744
3745 async fn setup_thread_view(
3746 agent: impl AgentServer + 'static,
3747 cx: &mut TestAppContext,
3748 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
3749 let fs = FakeFs::new(cx.executor());
3750 let project = Project::test(fs, [], cx).await;
3751 let (workspace, cx) =
3752 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3753
3754 let thread_store =
3755 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
3756 let text_thread_store =
3757 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
3758
3759 let thread_view = cx.update(|window, cx| {
3760 cx.new(|cx| {
3761 AcpThreadView::new(
3762 Rc::new(agent),
3763 workspace.downgrade(),
3764 project,
3765 thread_store.clone(),
3766 text_thread_store.clone(),
3767 None,
3768 window,
3769 cx,
3770 )
3771 })
3772 });
3773 cx.run_until_parked();
3774 (thread_view, cx)
3775 }
3776
3777 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
3778 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3779
3780 workspace
3781 .update_in(cx, |workspace, window, cx| {
3782 workspace.add_item_to_active_pane(
3783 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
3784 None,
3785 true,
3786 window,
3787 cx,
3788 );
3789 })
3790 .unwrap();
3791 }
3792
3793 struct ThreadViewItem(Entity<AcpThreadView>);
3794
3795 impl Item for ThreadViewItem {
3796 type Event = ();
3797
3798 fn include_in_nav_history() -> bool {
3799 false
3800 }
3801
3802 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3803 "Test".into()
3804 }
3805 }
3806
3807 impl EventEmitter<()> for ThreadViewItem {}
3808
3809 impl Focusable for ThreadViewItem {
3810 fn focus_handle(&self, cx: &App) -> FocusHandle {
3811 self.0.read(cx).focus_handle(cx).clone()
3812 }
3813 }
3814
3815 impl Render for ThreadViewItem {
3816 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3817 self.0.clone().into_any_element()
3818 }
3819 }
3820
3821 struct StubAgentServer<C> {
3822 connection: C,
3823 }
3824
3825 impl<C> StubAgentServer<C> {
3826 fn new(connection: C) -> Self {
3827 Self { connection }
3828 }
3829 }
3830
3831 impl StubAgentServer<StubAgentConnection> {
3832 fn default_response() -> Self {
3833 let conn = StubAgentConnection::new();
3834 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
3835 content: "Default response".into(),
3836 }]);
3837 Self::new(conn)
3838 }
3839 }
3840
3841 impl<C> AgentServer for StubAgentServer<C>
3842 where
3843 C: 'static + AgentConnection + Send + Clone,
3844 {
3845 fn logo(&self) -> ui::IconName {
3846 ui::IconName::Ai
3847 }
3848
3849 fn name(&self) -> AgentServerName {
3850 AgentServerName("Test".into())
3851 }
3852
3853 fn empty_state_headline(&self) -> &'static str {
3854 "Test"
3855 }
3856
3857 fn empty_state_message(&self) -> &'static str {
3858 "Test"
3859 }
3860
3861 fn connect(
3862 &self,
3863 _root_dir: &Path,
3864 _project: &Entity<Project>,
3865 _cx: &mut App,
3866 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3867 Task::ready(Ok(Rc::new(self.connection.clone())))
3868 }
3869 }
3870
3871 #[derive(Clone)]
3872 struct SaboteurAgentConnection;
3873
3874 impl AgentConnection for SaboteurAgentConnection {
3875 fn new_thread(
3876 self: Rc<Self>,
3877 project: Entity<Project>,
3878 _cwd: &Path,
3879 cx: &mut gpui::App,
3880 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3881 Task::ready(Ok(cx.new(|cx| {
3882 AcpThread::new(
3883 "SaboteurAgentConnection",
3884 self,
3885 project,
3886 SessionId("test".into()),
3887 cx,
3888 )
3889 })))
3890 }
3891
3892 fn auth_methods(&self) -> &[acp::AuthMethod] {
3893 &[]
3894 }
3895
3896 fn authenticate(
3897 &self,
3898 _method_id: acp::AuthMethodId,
3899 _cx: &mut App,
3900 ) -> Task<gpui::Result<()>> {
3901 unimplemented!()
3902 }
3903
3904 fn prompt(
3905 &self,
3906 _id: Option<acp_thread::UserMessageId>,
3907 _params: acp::PromptRequest,
3908 _cx: &mut App,
3909 ) -> Task<gpui::Result<acp::PromptResponse>> {
3910 Task::ready(Err(anyhow::anyhow!("Error prompting")))
3911 }
3912
3913 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3914 unimplemented!()
3915 }
3916
3917 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3918 self
3919 }
3920 }
3921
3922 pub(crate) fn init_test(cx: &mut TestAppContext) {
3923 cx.update(|cx| {
3924 let settings_store = SettingsStore::test(cx);
3925 cx.set_global(settings_store);
3926 language::init(cx);
3927 Project::init_settings(cx);
3928 AgentSettings::register(cx);
3929 workspace::init_settings(cx);
3930 ThemeSettings::register(cx);
3931 release_channel::init(SemanticVersion::default(), cx);
3932 EditorSettings::register(cx);
3933 });
3934 }
3935
3936 #[gpui::test]
3937 async fn test_rewind_views(cx: &mut TestAppContext) {
3938 init_test(cx);
3939
3940 let fs = FakeFs::new(cx.executor());
3941 fs.insert_tree(
3942 "/project",
3943 json!({
3944 "test1.txt": "old content 1",
3945 "test2.txt": "old content 2"
3946 }),
3947 )
3948 .await;
3949 let project = Project::test(fs, [Path::new("/project")], cx).await;
3950 let (workspace, cx) =
3951 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3952
3953 let thread_store =
3954 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
3955 let text_thread_store =
3956 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
3957
3958 let connection = Rc::new(StubAgentConnection::new());
3959 let thread_view = cx.update(|window, cx| {
3960 cx.new(|cx| {
3961 AcpThreadView::new(
3962 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
3963 workspace.downgrade(),
3964 project.clone(),
3965 thread_store.clone(),
3966 text_thread_store.clone(),
3967 None,
3968 window,
3969 cx,
3970 )
3971 })
3972 });
3973
3974 cx.run_until_parked();
3975
3976 let thread = thread_view
3977 .read_with(cx, |view, _| view.thread().cloned())
3978 .unwrap();
3979
3980 // First user message
3981 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
3982 id: acp::ToolCallId("tool1".into()),
3983 title: "Edit file 1".into(),
3984 kind: acp::ToolKind::Edit,
3985 status: acp::ToolCallStatus::Completed,
3986 content: vec![acp::ToolCallContent::Diff {
3987 diff: acp::Diff {
3988 path: "/project/test1.txt".into(),
3989 old_text: Some("old content 1".into()),
3990 new_text: "new content 1".into(),
3991 },
3992 }],
3993 locations: vec![],
3994 raw_input: None,
3995 raw_output: None,
3996 })]);
3997
3998 thread
3999 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4000 .await
4001 .unwrap();
4002 cx.run_until_parked();
4003
4004 thread.read_with(cx, |thread, _| {
4005 assert_eq!(thread.entries().len(), 2);
4006 });
4007
4008 thread_view.read_with(cx, |view, cx| {
4009 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4010 assert!(
4011 entry_view_state
4012 .entry(0)
4013 .unwrap()
4014 .message_editor()
4015 .is_some()
4016 );
4017 assert!(entry_view_state.entry(1).unwrap().has_content());
4018 });
4019 });
4020
4021 // Second user message
4022 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4023 id: acp::ToolCallId("tool2".into()),
4024 title: "Edit file 2".into(),
4025 kind: acp::ToolKind::Edit,
4026 status: acp::ToolCallStatus::Completed,
4027 content: vec![acp::ToolCallContent::Diff {
4028 diff: acp::Diff {
4029 path: "/project/test2.txt".into(),
4030 old_text: Some("old content 2".into()),
4031 new_text: "new content 2".into(),
4032 },
4033 }],
4034 locations: vec![],
4035 raw_input: None,
4036 raw_output: None,
4037 })]);
4038
4039 thread
4040 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4041 .await
4042 .unwrap();
4043 cx.run_until_parked();
4044
4045 let second_user_message_id = thread.read_with(cx, |thread, _| {
4046 assert_eq!(thread.entries().len(), 4);
4047 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4048 panic!();
4049 };
4050 user_message.id.clone().unwrap()
4051 });
4052
4053 thread_view.read_with(cx, |view, cx| {
4054 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4055 assert!(
4056 entry_view_state
4057 .entry(0)
4058 .unwrap()
4059 .message_editor()
4060 .is_some()
4061 );
4062 assert!(entry_view_state.entry(1).unwrap().has_content());
4063 assert!(
4064 entry_view_state
4065 .entry(2)
4066 .unwrap()
4067 .message_editor()
4068 .is_some()
4069 );
4070 assert!(entry_view_state.entry(3).unwrap().has_content());
4071 });
4072 });
4073
4074 // Rewind to first message
4075 thread
4076 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4077 .await
4078 .unwrap();
4079
4080 cx.run_until_parked();
4081
4082 thread.read_with(cx, |thread, _| {
4083 assert_eq!(thread.entries().len(), 2);
4084 });
4085
4086 thread_view.read_with(cx, |view, cx| {
4087 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4088 assert!(
4089 entry_view_state
4090 .entry(0)
4091 .unwrap()
4092 .message_editor()
4093 .is_some()
4094 );
4095 assert!(entry_view_state.entry(1).unwrap().has_content());
4096
4097 // Old views should be dropped
4098 assert!(entry_view_state.entry(2).is_none());
4099 assert!(entry_view_state.entry(3).is_none());
4100 });
4101 });
4102 }
4103
4104 #[gpui::test]
4105 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4106 init_test(cx);
4107
4108 let connection = StubAgentConnection::new();
4109
4110 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4111 content: acp::ContentBlock::Text(acp::TextContent {
4112 text: "Response".into(),
4113 annotations: None,
4114 }),
4115 }]);
4116
4117 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4118 add_to_workspace(thread_view.clone(), cx);
4119
4120 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4121 message_editor.update_in(cx, |editor, window, cx| {
4122 editor.set_text("Original message to edit", window, cx);
4123 });
4124 thread_view.update_in(cx, |thread_view, window, cx| {
4125 thread_view.send(window, cx);
4126 });
4127
4128 cx.run_until_parked();
4129
4130 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4131 assert_eq!(view.editing_message, None);
4132
4133 view.entry_view_state
4134 .read(cx)
4135 .entry(0)
4136 .unwrap()
4137 .message_editor()
4138 .unwrap()
4139 .clone()
4140 });
4141
4142 // Focus
4143 cx.focus(&user_message_editor);
4144 thread_view.read_with(cx, |view, _cx| {
4145 assert_eq!(view.editing_message, Some(0));
4146 });
4147
4148 // Edit
4149 user_message_editor.update_in(cx, |editor, window, cx| {
4150 editor.set_text("Edited message content", window, cx);
4151 });
4152
4153 // Cancel
4154 user_message_editor.update_in(cx, |_editor, window, cx| {
4155 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4156 });
4157
4158 thread_view.read_with(cx, |view, _cx| {
4159 assert_eq!(view.editing_message, None);
4160 });
4161
4162 user_message_editor.read_with(cx, |editor, cx| {
4163 assert_eq!(editor.text(cx), "Original message to edit");
4164 });
4165 }
4166
4167 #[gpui::test]
4168 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4169 init_test(cx);
4170
4171 let connection = StubAgentConnection::new();
4172
4173 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4174 content: acp::ContentBlock::Text(acp::TextContent {
4175 text: "Response".into(),
4176 annotations: None,
4177 }),
4178 }]);
4179
4180 let (thread_view, cx) =
4181 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4182 add_to_workspace(thread_view.clone(), cx);
4183
4184 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4185 message_editor.update_in(cx, |editor, window, cx| {
4186 editor.set_text("Original message to edit", window, cx);
4187 });
4188 thread_view.update_in(cx, |thread_view, window, cx| {
4189 thread_view.send(window, cx);
4190 });
4191
4192 cx.run_until_parked();
4193
4194 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4195 assert_eq!(view.editing_message, None);
4196 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
4197
4198 view.entry_view_state
4199 .read(cx)
4200 .entry(0)
4201 .unwrap()
4202 .message_editor()
4203 .unwrap()
4204 .clone()
4205 });
4206
4207 // Focus
4208 cx.focus(&user_message_editor);
4209
4210 // Edit
4211 user_message_editor.update_in(cx, |editor, window, cx| {
4212 editor.set_text("Edited message content", window, cx);
4213 });
4214
4215 // Send
4216 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4217 content: acp::ContentBlock::Text(acp::TextContent {
4218 text: "New Response".into(),
4219 annotations: None,
4220 }),
4221 }]);
4222
4223 user_message_editor.update_in(cx, |_editor, window, cx| {
4224 window.dispatch_action(Box::new(Chat), cx);
4225 });
4226
4227 cx.run_until_parked();
4228
4229 thread_view.read_with(cx, |view, cx| {
4230 assert_eq!(view.editing_message, None);
4231
4232 let entries = view.thread().unwrap().read(cx).entries();
4233 assert_eq!(entries.len(), 2);
4234 assert_eq!(
4235 entries[0].to_markdown(cx),
4236 "## User\n\nEdited message content\n\n"
4237 );
4238 assert_eq!(
4239 entries[1].to_markdown(cx),
4240 "## Assistant\n\nNew Response\n\n"
4241 );
4242
4243 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
4244 assert!(!state.entry(1).unwrap().has_content());
4245 state.entry(0).unwrap().message_editor().unwrap().clone()
4246 });
4247
4248 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4249 })
4250 }
4251
4252 #[gpui::test]
4253 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4254 init_test(cx);
4255
4256 let connection = StubAgentConnection::new();
4257
4258 let (thread_view, cx) =
4259 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4260 add_to_workspace(thread_view.clone(), cx);
4261
4262 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4263 message_editor.update_in(cx, |editor, window, cx| {
4264 editor.set_text("Original message to edit", window, cx);
4265 });
4266 thread_view.update_in(cx, |thread_view, window, cx| {
4267 thread_view.send(window, cx);
4268 });
4269
4270 cx.run_until_parked();
4271
4272 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
4273 let thread = view.thread().unwrap().read(cx);
4274 assert_eq!(thread.entries().len(), 1);
4275
4276 let editor = view
4277 .entry_view_state
4278 .read(cx)
4279 .entry(0)
4280 .unwrap()
4281 .message_editor()
4282 .unwrap()
4283 .clone();
4284
4285 (editor, thread.session_id().clone())
4286 });
4287
4288 // Focus
4289 cx.focus(&user_message_editor);
4290
4291 thread_view.read_with(cx, |view, _cx| {
4292 assert_eq!(view.editing_message, Some(0));
4293 });
4294
4295 // Edit
4296 user_message_editor.update_in(cx, |editor, window, cx| {
4297 editor.set_text("Edited message content", window, cx);
4298 });
4299
4300 thread_view.read_with(cx, |view, _cx| {
4301 assert_eq!(view.editing_message, Some(0));
4302 });
4303
4304 // Finish streaming response
4305 cx.update(|_, cx| {
4306 connection.send_update(
4307 session_id.clone(),
4308 acp::SessionUpdate::AgentMessageChunk {
4309 content: acp::ContentBlock::Text(acp::TextContent {
4310 text: "Response".into(),
4311 annotations: None,
4312 }),
4313 },
4314 cx,
4315 );
4316 connection.end_turn(session_id, acp::StopReason::EndTurn);
4317 });
4318
4319 thread_view.read_with(cx, |view, _cx| {
4320 assert_eq!(view.editing_message, Some(0));
4321 });
4322
4323 cx.run_until_parked();
4324
4325 // Should still be editing
4326 cx.update(|window, cx| {
4327 assert!(user_message_editor.focus_handle(cx).is_focused(window));
4328 assert_eq!(thread_view.read(cx).editing_message, Some(0));
4329 assert_eq!(
4330 user_message_editor.read(cx).text(cx),
4331 "Edited message content"
4332 );
4333 });
4334 }
4335
4336 #[gpui::test]
4337 async fn test_interrupt(cx: &mut TestAppContext) {
4338 init_test(cx);
4339
4340 let connection = StubAgentConnection::new();
4341
4342 let (thread_view, cx) =
4343 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4344 add_to_workspace(thread_view.clone(), cx);
4345
4346 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4347 message_editor.update_in(cx, |editor, window, cx| {
4348 editor.set_text("Message 1", window, cx);
4349 });
4350 thread_view.update_in(cx, |thread_view, window, cx| {
4351 thread_view.send(window, cx);
4352 });
4353
4354 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4355 let thread = view.thread().unwrap();
4356
4357 (thread.clone(), thread.read(cx).session_id().clone())
4358 });
4359
4360 cx.run_until_parked();
4361
4362 cx.update(|_, cx| {
4363 connection.send_update(
4364 session_id.clone(),
4365 acp::SessionUpdate::AgentMessageChunk {
4366 content: "Message 1 resp".into(),
4367 },
4368 cx,
4369 );
4370 });
4371
4372 cx.run_until_parked();
4373
4374 thread.read_with(cx, |thread, cx| {
4375 assert_eq!(
4376 thread.to_markdown(cx),
4377 indoc::indoc! {"
4378 ## User
4379
4380 Message 1
4381
4382 ## Assistant
4383
4384 Message 1 resp
4385
4386 "}
4387 )
4388 });
4389
4390 message_editor.update_in(cx, |editor, window, cx| {
4391 editor.set_text("Message 2", window, cx);
4392 });
4393 thread_view.update_in(cx, |thread_view, window, cx| {
4394 thread_view.send(window, cx);
4395 });
4396
4397 cx.update(|_, cx| {
4398 // Simulate a response sent after beginning to cancel
4399 connection.send_update(
4400 session_id.clone(),
4401 acp::SessionUpdate::AgentMessageChunk {
4402 content: "onse".into(),
4403 },
4404 cx,
4405 );
4406 });
4407
4408 cx.run_until_parked();
4409
4410 // Last Message 1 response should appear before Message 2
4411 thread.read_with(cx, |thread, cx| {
4412 assert_eq!(
4413 thread.to_markdown(cx),
4414 indoc::indoc! {"
4415 ## User
4416
4417 Message 1
4418
4419 ## Assistant
4420
4421 Message 1 response
4422
4423 ## User
4424
4425 Message 2
4426
4427 "}
4428 )
4429 });
4430
4431 cx.update(|_, cx| {
4432 connection.send_update(
4433 session_id.clone(),
4434 acp::SessionUpdate::AgentMessageChunk {
4435 content: "Message 2 response".into(),
4436 },
4437 cx,
4438 );
4439 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4440 });
4441
4442 cx.run_until_parked();
4443
4444 thread.read_with(cx, |thread, cx| {
4445 assert_eq!(
4446 thread.to_markdown(cx),
4447 indoc::indoc! {"
4448 ## User
4449
4450 Message 1
4451
4452 ## Assistant
4453
4454 Message 1 response
4455
4456 ## User
4457
4458 Message 2
4459
4460 ## Assistant
4461
4462 Message 2 response
4463
4464 "}
4465 )
4466 });
4467 }
4468}