1use std::{
2 cell::RefCell,
3 collections::HashSet,
4 fmt::Display,
5 rc::{Rc, Weak},
6 sync::Arc,
7 time::Duration,
8};
9
10use agent_client_protocol as acp;
11use collections::HashMap;
12use gpui::{
13 App, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment,
14 ListState, StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list,
15 prelude::*,
16};
17use language::LanguageRegistry;
18use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
19use project::Project;
20use settings::Settings;
21use theme::ThemeSettings;
22use ui::{Tooltip, prelude::*};
23use util::ResultExt as _;
24use workspace::{
25 Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
26};
27
28actions!(dev, [OpenAcpLogs]);
29
30pub fn init(cx: &mut App) {
31 cx.observe_new(
32 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
33 workspace.register_action(|workspace, _: &OpenAcpLogs, window, cx| {
34 let acp_tools =
35 Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx)));
36 workspace.add_item_to_active_pane(acp_tools, None, true, window, cx);
37 });
38 },
39 )
40 .detach();
41}
42
43struct GlobalAcpConnectionRegistry(Entity<AcpConnectionRegistry>);
44
45impl Global for GlobalAcpConnectionRegistry {}
46
47#[derive(Default)]
48pub struct AcpConnectionRegistry {
49 active_connection: RefCell<Option<ActiveConnection>>,
50}
51
52struct ActiveConnection {
53 server_name: SharedString,
54 connection: Weak<acp::ClientSideConnection>,
55}
56
57impl AcpConnectionRegistry {
58 pub fn default_global(cx: &mut App) -> Entity<Self> {
59 if cx.has_global::<GlobalAcpConnectionRegistry>() {
60 cx.global::<GlobalAcpConnectionRegistry>().0.clone()
61 } else {
62 let registry = cx.new(|_cx| AcpConnectionRegistry::default());
63 cx.set_global(GlobalAcpConnectionRegistry(registry.clone()));
64 registry
65 }
66 }
67
68 pub fn set_active_connection(
69 &self,
70 server_name: impl Into<SharedString>,
71 connection: &Rc<acp::ClientSideConnection>,
72 cx: &mut Context<Self>,
73 ) {
74 self.active_connection.replace(Some(ActiveConnection {
75 server_name: server_name.into(),
76 connection: Rc::downgrade(connection),
77 }));
78 cx.notify();
79 }
80}
81
82struct AcpTools {
83 project: Entity<Project>,
84 focus_handle: FocusHandle,
85 expanded: HashSet<usize>,
86 watched_connection: Option<WatchedConnection>,
87 connection_registry: Entity<AcpConnectionRegistry>,
88 _subscription: Subscription,
89}
90
91struct WatchedConnection {
92 server_name: SharedString,
93 messages: Vec<WatchedConnectionMessage>,
94 list_state: ListState,
95 connection: Weak<acp::ClientSideConnection>,
96 incoming_request_methods: HashMap<acp::RequestId, Arc<str>>,
97 outgoing_request_methods: HashMap<acp::RequestId, Arc<str>>,
98 _task: Task<()>,
99}
100
101impl AcpTools {
102 fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
103 let connection_registry = AcpConnectionRegistry::default_global(cx);
104
105 let subscription = cx.observe(&connection_registry, |this, _, cx| {
106 this.update_connection(cx);
107 cx.notify();
108 });
109
110 let mut this = Self {
111 project,
112 focus_handle: cx.focus_handle(),
113 expanded: HashSet::default(),
114 watched_connection: None,
115 connection_registry,
116 _subscription: subscription,
117 };
118 this.update_connection(cx);
119 this
120 }
121
122 fn update_connection(&mut self, cx: &mut Context<Self>) {
123 let active_connection = self.connection_registry.read(cx).active_connection.borrow();
124 let Some(active_connection) = active_connection.as_ref() else {
125 return;
126 };
127
128 if let Some(watched_connection) = self.watched_connection.as_ref() {
129 if Weak::ptr_eq(
130 &watched_connection.connection,
131 &active_connection.connection,
132 ) {
133 return;
134 }
135 }
136
137 if let Some(connection) = active_connection.connection.upgrade() {
138 let mut receiver = connection.subscribe();
139 let task = cx.spawn(async move |this, cx| {
140 while let Ok(message) = receiver.recv().await {
141 this.update(cx, |this, cx| {
142 this.push_stream_message(message, cx);
143 })
144 .ok();
145 }
146 });
147
148 self.watched_connection = Some(WatchedConnection {
149 server_name: active_connection.server_name.clone(),
150 messages: vec![],
151 list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
152 connection: active_connection.connection.clone(),
153 incoming_request_methods: HashMap::default(),
154 outgoing_request_methods: HashMap::default(),
155 _task: task,
156 });
157 }
158 }
159
160 fn push_stream_message(&mut self, stream_message: acp::StreamMessage, cx: &mut Context<Self>) {
161 let Some(connection) = self.watched_connection.as_mut() else {
162 return;
163 };
164 let language_registry = self.project.read(cx).languages().clone();
165 let index = connection.messages.len();
166
167 let (request_id, method, message_type, params) = match stream_message.message {
168 acp::StreamMessageContent::Request { id, method, params } => {
169 let method_map = match stream_message.direction {
170 acp::StreamMessageDirection::Incoming => {
171 &mut connection.incoming_request_methods
172 }
173 acp::StreamMessageDirection::Outgoing => {
174 &mut connection.outgoing_request_methods
175 }
176 };
177
178 method_map.insert(id.clone(), method.clone());
179 (Some(id), method.into(), MessageType::Request, Ok(params))
180 }
181 acp::StreamMessageContent::Response { id, result } => {
182 let method_map = match stream_message.direction {
183 acp::StreamMessageDirection::Incoming => {
184 &mut connection.outgoing_request_methods
185 }
186 acp::StreamMessageDirection::Outgoing => {
187 &mut connection.incoming_request_methods
188 }
189 };
190
191 if let Some(method) = method_map.remove(&id) {
192 (Some(id), method.into(), MessageType::Response, result)
193 } else {
194 (
195 Some(id),
196 "[unrecognized response]".into(),
197 MessageType::Response,
198 result,
199 )
200 }
201 }
202 acp::StreamMessageContent::Notification { method, params } => {
203 (None, method.into(), MessageType::Notification, Ok(params))
204 }
205 };
206
207 let message = WatchedConnectionMessage {
208 name: method,
209 message_type,
210 request_id,
211 direction: stream_message.direction,
212 collapsed_params_md: match params.as_ref() {
213 Ok(params) => params
214 .as_ref()
215 .map(|params| collapsed_params_md(params, &language_registry, cx)),
216 Err(err) => {
217 if let Ok(err) = &serde_json::to_value(err) {
218 Some(collapsed_params_md(&err, &language_registry, cx))
219 } else {
220 None
221 }
222 }
223 },
224
225 expanded_params_md: None,
226 params,
227 };
228
229 connection.messages.push(message);
230 connection.list_state.splice(index..index, 1);
231 cx.notify();
232 }
233
234 fn serialize_observed_messages(&self) -> Option<String> {
235 let connection = self.watched_connection.as_ref()?;
236
237 let messages: Vec<serde_json::Value> = connection
238 .messages
239 .iter()
240 .filter_map(|message| {
241 let params = match &message.params {
242 Ok(Some(params)) => params.clone(),
243 Ok(None) => serde_json::Value::Null,
244 Err(err) => serde_json::to_value(err).ok()?,
245 };
246 Some(serde_json::json!({
247 "_direction": match message.direction {
248 acp::StreamMessageDirection::Incoming => "incoming",
249 acp::StreamMessageDirection::Outgoing => "outgoing",
250 },
251 "_type": message.message_type.to_string().to_lowercase(),
252 "id": message.request_id,
253 "method": message.name.to_string(),
254 "params": params,
255 }))
256 })
257 .collect();
258
259 serde_json::to_string_pretty(&messages).ok()
260 }
261
262 fn clear_messages(&mut self, cx: &mut Context<Self>) {
263 if let Some(connection) = self.watched_connection.as_mut() {
264 connection.messages.clear();
265 connection.list_state.reset(0);
266 self.expanded.clear();
267 cx.notify();
268 }
269 }
270
271 fn render_message(
272 &mut self,
273 index: usize,
274 window: &mut Window,
275 cx: &mut Context<Self>,
276 ) -> AnyElement {
277 let Some(connection) = self.watched_connection.as_ref() else {
278 return Empty.into_any();
279 };
280
281 let Some(message) = connection.messages.get(index) else {
282 return Empty.into_any();
283 };
284
285 let base_size = TextSize::Editor.rems(cx);
286
287 let theme_settings = ThemeSettings::get_global(cx);
288 let text_style = window.text_style();
289
290 let colors = cx.theme().colors();
291 let expanded = self.expanded.contains(&index);
292
293 v_flex()
294 .w_full()
295 .px_4()
296 .py_3()
297 .border_color(colors.border)
298 .border_b_1()
299 .gap_2()
300 .items_start()
301 .font_buffer(cx)
302 .text_size(base_size)
303 .id(index)
304 .group("message")
305 .hover(|this| this.bg(colors.element_background.opacity(0.5)))
306 .on_click(cx.listener(move |this, _, _, cx| {
307 if this.expanded.contains(&index) {
308 this.expanded.remove(&index);
309 } else {
310 this.expanded.insert(index);
311 let Some(connection) = &mut this.watched_connection else {
312 return;
313 };
314 let Some(message) = connection.messages.get_mut(index) else {
315 return;
316 };
317 message.expanded(this.project.read(cx).languages().clone(), cx);
318 connection.list_state.scroll_to_reveal_item(index);
319 }
320 cx.notify()
321 }))
322 .child(
323 h_flex()
324 .w_full()
325 .gap_2()
326 .items_center()
327 .flex_shrink_0()
328 .child(match message.direction {
329 acp::StreamMessageDirection::Incoming => {
330 ui::Icon::new(ui::IconName::ArrowDown).color(Color::Error)
331 }
332 acp::StreamMessageDirection::Outgoing => {
333 ui::Icon::new(ui::IconName::ArrowUp).color(Color::Success)
334 }
335 })
336 .child(
337 Label::new(message.name.clone())
338 .buffer_font(cx)
339 .color(Color::Muted),
340 )
341 .child(div().flex_1())
342 .child(
343 div()
344 .child(ui::Chip::new(message.message_type.to_string()))
345 .visible_on_hover("message"),
346 )
347 .children(
348 message
349 .request_id
350 .as_ref()
351 .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))),
352 ),
353 )
354 // I'm aware using markdown is a hack. Trying to get something working for the demo.
355 // Will clean up soon!
356 .when_some(
357 if expanded {
358 message.expanded_params_md.clone()
359 } else {
360 message.collapsed_params_md.clone()
361 },
362 |this, params| {
363 this.child(
364 div().pl_6().w_full().child(
365 MarkdownElement::new(
366 params,
367 MarkdownStyle {
368 base_text_style: text_style,
369 selection_background_color: colors.element_selection_background,
370 syntax: cx.theme().syntax().clone(),
371 code_block_overflow_x_scroll: true,
372 code_block: StyleRefinement {
373 text: Some(TextStyleRefinement {
374 font_family: Some(
375 theme_settings.buffer_font.family.clone(),
376 ),
377 font_size: Some((base_size * 0.8).into()),
378 ..Default::default()
379 }),
380 ..Default::default()
381 },
382 ..Default::default()
383 },
384 )
385 .code_block_renderer(
386 CodeBlockRenderer::Default {
387 copy_button: false,
388 copy_button_on_hover: expanded,
389 border: false,
390 },
391 ),
392 ),
393 )
394 },
395 )
396 .into_any()
397 }
398}
399
400struct WatchedConnectionMessage {
401 name: SharedString,
402 request_id: Option<acp::RequestId>,
403 direction: acp::StreamMessageDirection,
404 message_type: MessageType,
405 params: Result<Option<serde_json::Value>, acp::Error>,
406 collapsed_params_md: Option<Entity<Markdown>>,
407 expanded_params_md: Option<Entity<Markdown>>,
408}
409
410impl WatchedConnectionMessage {
411 fn expanded(&mut self, language_registry: Arc<LanguageRegistry>, cx: &mut App) {
412 let params_md = match &self.params {
413 Ok(Some(params)) => Some(expanded_params_md(params, &language_registry, cx)),
414 Err(err) => {
415 if let Some(err) = &serde_json::to_value(err).log_err() {
416 Some(expanded_params_md(&err, &language_registry, cx))
417 } else {
418 None
419 }
420 }
421 _ => None,
422 };
423 self.expanded_params_md = params_md;
424 }
425}
426
427fn collapsed_params_md(
428 params: &serde_json::Value,
429 language_registry: &Arc<LanguageRegistry>,
430 cx: &mut App,
431) -> Entity<Markdown> {
432 let params_json = serde_json::to_string(params).unwrap_or_default();
433 let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4);
434
435 for ch in params_json.chars() {
436 match ch {
437 '{' => spaced_out_json.push_str("{ "),
438 '}' => spaced_out_json.push_str(" }"),
439 ':' => spaced_out_json.push_str(": "),
440 ',' => spaced_out_json.push_str(", "),
441 c => spaced_out_json.push(c),
442 }
443 }
444
445 let params_md = format!("```json\n{}\n```", spaced_out_json);
446 cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
447}
448
449fn expanded_params_md(
450 params: &serde_json::Value,
451 language_registry: &Arc<LanguageRegistry>,
452 cx: &mut App,
453) -> Entity<Markdown> {
454 let params_json = serde_json::to_string_pretty(params).unwrap_or_default();
455 let params_md = format!("```json\n{}\n```", params_json);
456 cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
457}
458
459enum MessageType {
460 Request,
461 Response,
462 Notification,
463}
464
465impl Display for MessageType {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 match self {
468 MessageType::Request => write!(f, "Request"),
469 MessageType::Response => write!(f, "Response"),
470 MessageType::Notification => write!(f, "Notification"),
471 }
472 }
473}
474
475enum AcpToolsEvent {}
476
477impl EventEmitter<AcpToolsEvent> for AcpTools {}
478
479impl Item for AcpTools {
480 type Event = AcpToolsEvent;
481
482 fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
483 format!(
484 "ACP: {}",
485 self.watched_connection
486 .as_ref()
487 .map_or("Disconnected", |connection| &connection.server_name)
488 )
489 .into()
490 }
491
492 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
493 Some(ui::Icon::new(IconName::Thread))
494 }
495}
496
497impl Focusable for AcpTools {
498 fn focus_handle(&self, _cx: &App) -> FocusHandle {
499 self.focus_handle.clone()
500 }
501}
502
503impl Render for AcpTools {
504 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
505 v_flex()
506 .track_focus(&self.focus_handle)
507 .size_full()
508 .bg(cx.theme().colors().editor_background)
509 .child(match self.watched_connection.as_ref() {
510 Some(connection) => {
511 if connection.messages.is_empty() {
512 h_flex()
513 .size_full()
514 .justify_center()
515 .items_center()
516 .child("No messages recorded yet")
517 .into_any()
518 } else {
519 list(
520 connection.list_state.clone(),
521 cx.processor(Self::render_message),
522 )
523 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
524 .flex_grow()
525 .into_any()
526 }
527 }
528 None => h_flex()
529 .size_full()
530 .justify_center()
531 .items_center()
532 .child("No active connection")
533 .into_any(),
534 })
535 }
536}
537
538pub struct AcpToolsToolbarItemView {
539 acp_tools: Option<Entity<AcpTools>>,
540 just_copied: bool,
541}
542
543impl AcpToolsToolbarItemView {
544 pub fn new() -> Self {
545 Self {
546 acp_tools: None,
547 just_copied: false,
548 }
549 }
550}
551
552impl Render for AcpToolsToolbarItemView {
553 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
554 let Some(acp_tools) = self.acp_tools.as_ref() else {
555 return Empty.into_any_element();
556 };
557
558 let acp_tools = acp_tools.clone();
559 let has_messages = acp_tools
560 .read(cx)
561 .watched_connection
562 .as_ref()
563 .is_some_and(|connection| !connection.messages.is_empty());
564
565 h_flex()
566 .gap_2()
567 .child({
568 let acp_tools = acp_tools.clone();
569 IconButton::new(
570 "copy_all_messages",
571 if self.just_copied {
572 IconName::Check
573 } else {
574 IconName::Copy
575 },
576 )
577 .icon_size(IconSize::Small)
578 .tooltip(Tooltip::text(if self.just_copied {
579 "Copied!"
580 } else {
581 "Copy All Messages"
582 }))
583 .disabled(!has_messages)
584 .on_click(cx.listener(move |this, _, _window, cx| {
585 if let Some(content) = acp_tools.read(cx).serialize_observed_messages() {
586 cx.write_to_clipboard(ClipboardItem::new_string(content));
587
588 this.just_copied = true;
589 cx.spawn(async move |this, cx| {
590 cx.background_executor().timer(Duration::from_secs(2)).await;
591 this.update(cx, |this, cx| {
592 this.just_copied = false;
593 cx.notify();
594 })
595 })
596 .detach();
597 }
598 }))
599 })
600 .child(
601 IconButton::new("clear_messages", IconName::Trash)
602 .icon_size(IconSize::Small)
603 .tooltip(Tooltip::text("Clear Messages"))
604 .disabled(!has_messages)
605 .on_click(cx.listener(move |_this, _, _window, cx| {
606 acp_tools.update(cx, |acp_tools, cx| {
607 acp_tools.clear_messages(cx);
608 });
609 })),
610 )
611 .into_any()
612 }
613}
614
615impl EventEmitter<ToolbarItemEvent> for AcpToolsToolbarItemView {}
616
617impl ToolbarItemView for AcpToolsToolbarItemView {
618 fn set_active_pane_item(
619 &mut self,
620 active_pane_item: Option<&dyn ItemHandle>,
621 _window: &mut Window,
622 cx: &mut Context<Self>,
623 ) -> ToolbarItemLocation {
624 if let Some(item) = active_pane_item
625 && let Some(acp_tools) = item.downcast::<AcpTools>()
626 {
627 self.acp_tools = Some(acp_tools);
628 cx.notify();
629 return ToolbarItemLocation::PrimaryRight;
630 }
631 if self.acp_tools.take().is_some() {
632 cx.notify();
633 }
634 ToolbarItemLocation::Hidden
635 }
636}