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