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