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