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