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