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