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<i32, Arc<str>>,
97 outgoing_request_methods: HashMap<i32, 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, 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 render_message(
263 &mut self,
264 index: usize,
265 window: &mut Window,
266 cx: &mut Context<Self>,
267 ) -> AnyElement {
268 let Some(connection) = self.watched_connection.as_ref() else {
269 return Empty.into_any();
270 };
271
272 let Some(message) = connection.messages.get(index) else {
273 return Empty.into_any();
274 };
275
276 let base_size = TextSize::Editor.rems(cx);
277
278 let theme_settings = ThemeSettings::get_global(cx);
279 let text_style = window.text_style();
280
281 let colors = cx.theme().colors();
282 let expanded = self.expanded.contains(&index);
283
284 v_flex()
285 .w_full()
286 .px_4()
287 .py_3()
288 .border_color(colors.border)
289 .border_b_1()
290 .gap_2()
291 .items_start()
292 .font_buffer(cx)
293 .text_size(base_size)
294 .id(index)
295 .group("message")
296 .hover(|this| this.bg(colors.element_background.opacity(0.5)))
297 .on_click(cx.listener(move |this, _, _, cx| {
298 if this.expanded.contains(&index) {
299 this.expanded.remove(&index);
300 } else {
301 this.expanded.insert(index);
302 let Some(connection) = &mut this.watched_connection else {
303 return;
304 };
305 let Some(message) = connection.messages.get_mut(index) else {
306 return;
307 };
308 message.expanded(this.project.read(cx).languages().clone(), cx);
309 connection.list_state.scroll_to_reveal_item(index);
310 }
311 cx.notify()
312 }))
313 .child(
314 h_flex()
315 .w_full()
316 .gap_2()
317 .items_center()
318 .flex_shrink_0()
319 .child(match message.direction {
320 acp::StreamMessageDirection::Incoming => {
321 ui::Icon::new(ui::IconName::ArrowDown).color(Color::Error)
322 }
323 acp::StreamMessageDirection::Outgoing => {
324 ui::Icon::new(ui::IconName::ArrowUp).color(Color::Success)
325 }
326 })
327 .child(
328 Label::new(message.name.clone())
329 .buffer_font(cx)
330 .color(Color::Muted),
331 )
332 .child(div().flex_1())
333 .child(
334 div()
335 .child(ui::Chip::new(message.message_type.to_string()))
336 .visible_on_hover("message"),
337 )
338 .children(
339 message
340 .request_id
341 .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))),
342 ),
343 )
344 // I'm aware using markdown is a hack. Trying to get something working for the demo.
345 // Will clean up soon!
346 .when_some(
347 if expanded {
348 message.expanded_params_md.clone()
349 } else {
350 message.collapsed_params_md.clone()
351 },
352 |this, params| {
353 this.child(
354 div().pl_6().w_full().child(
355 MarkdownElement::new(
356 params,
357 MarkdownStyle {
358 base_text_style: text_style,
359 selection_background_color: colors.element_selection_background,
360 syntax: cx.theme().syntax().clone(),
361 code_block_overflow_x_scroll: true,
362 code_block: StyleRefinement {
363 text: Some(TextStyleRefinement {
364 font_family: Some(
365 theme_settings.buffer_font.family.clone(),
366 ),
367 font_size: Some((base_size * 0.8).into()),
368 ..Default::default()
369 }),
370 ..Default::default()
371 },
372 ..Default::default()
373 },
374 )
375 .code_block_renderer(
376 CodeBlockRenderer::Default {
377 copy_button: false,
378 copy_button_on_hover: expanded,
379 border: false,
380 },
381 ),
382 ),
383 )
384 },
385 )
386 .into_any()
387 }
388}
389
390struct WatchedConnectionMessage {
391 name: SharedString,
392 request_id: Option<i32>,
393 direction: acp::StreamMessageDirection,
394 message_type: MessageType,
395 params: Result<Option<serde_json::Value>, acp::Error>,
396 collapsed_params_md: Option<Entity<Markdown>>,
397 expanded_params_md: Option<Entity<Markdown>>,
398}
399
400impl WatchedConnectionMessage {
401 fn expanded(&mut self, language_registry: Arc<LanguageRegistry>, cx: &mut App) {
402 let params_md = match &self.params {
403 Ok(Some(params)) => Some(expanded_params_md(params, &language_registry, cx)),
404 Err(err) => {
405 if let Some(err) = &serde_json::to_value(err).log_err() {
406 Some(expanded_params_md(&err, &language_registry, cx))
407 } else {
408 None
409 }
410 }
411 _ => None,
412 };
413 self.expanded_params_md = params_md;
414 }
415}
416
417fn collapsed_params_md(
418 params: &serde_json::Value,
419 language_registry: &Arc<LanguageRegistry>,
420 cx: &mut App,
421) -> Entity<Markdown> {
422 let params_json = serde_json::to_string(params).unwrap_or_default();
423 let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4);
424
425 for ch in params_json.chars() {
426 match ch {
427 '{' => spaced_out_json.push_str("{ "),
428 '}' => spaced_out_json.push_str(" }"),
429 ':' => spaced_out_json.push_str(": "),
430 ',' => spaced_out_json.push_str(", "),
431 c => spaced_out_json.push(c),
432 }
433 }
434
435 let params_md = format!("```json\n{}\n```", spaced_out_json);
436 cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
437}
438
439fn expanded_params_md(
440 params: &serde_json::Value,
441 language_registry: &Arc<LanguageRegistry>,
442 cx: &mut App,
443) -> Entity<Markdown> {
444 let params_json = serde_json::to_string_pretty(params).unwrap_or_default();
445 let params_md = format!("```json\n{}\n```", params_json);
446 cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
447}
448
449enum MessageType {
450 Request,
451 Response,
452 Notification,
453}
454
455impl Display for MessageType {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 match self {
458 MessageType::Request => write!(f, "Request"),
459 MessageType::Response => write!(f, "Response"),
460 MessageType::Notification => write!(f, "Notification"),
461 }
462 }
463}
464
465enum AcpToolsEvent {}
466
467impl EventEmitter<AcpToolsEvent> for AcpTools {}
468
469impl Item for AcpTools {
470 type Event = AcpToolsEvent;
471
472 fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
473 format!(
474 "ACP: {}",
475 self.watched_connection
476 .as_ref()
477 .map_or("Disconnected", |connection| &connection.server_name)
478 )
479 .into()
480 }
481
482 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
483 Some(ui::Icon::new(IconName::Thread))
484 }
485}
486
487impl Focusable for AcpTools {
488 fn focus_handle(&self, _cx: &App) -> FocusHandle {
489 self.focus_handle.clone()
490 }
491}
492
493impl Render for AcpTools {
494 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
495 v_flex()
496 .track_focus(&self.focus_handle)
497 .size_full()
498 .bg(cx.theme().colors().editor_background)
499 .child(match self.watched_connection.as_ref() {
500 Some(connection) => {
501 if connection.messages.is_empty() {
502 h_flex()
503 .size_full()
504 .justify_center()
505 .items_center()
506 .child("No messages recorded yet")
507 .into_any()
508 } else {
509 list(
510 connection.list_state.clone(),
511 cx.processor(Self::render_message),
512 )
513 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
514 .flex_grow()
515 .into_any()
516 }
517 }
518 None => h_flex()
519 .size_full()
520 .justify_center()
521 .items_center()
522 .child("No active connection")
523 .into_any(),
524 })
525 }
526}
527
528pub struct AcpToolsToolbarItemView {
529 acp_tools: Option<Entity<AcpTools>>,
530 just_copied: bool,
531}
532
533impl AcpToolsToolbarItemView {
534 pub fn new() -> Self {
535 Self {
536 acp_tools: None,
537 just_copied: false,
538 }
539 }
540}
541
542impl Render for AcpToolsToolbarItemView {
543 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
544 let Some(acp_tools) = self.acp_tools.as_ref() else {
545 return Empty.into_any_element();
546 };
547
548 let acp_tools = acp_tools.clone();
549
550 h_flex()
551 .gap_2()
552 .child(
553 IconButton::new(
554 "copy_all_messages",
555 if self.just_copied {
556 IconName::Check
557 } else {
558 IconName::Copy
559 },
560 )
561 .icon_size(IconSize::Small)
562 .tooltip(Tooltip::text(if self.just_copied {
563 "Copied!"
564 } else {
565 "Copy All Messages"
566 }))
567 .disabled(
568 acp_tools
569 .read(cx)
570 .watched_connection
571 .as_ref()
572 .is_none_or(|connection| connection.messages.is_empty()),
573 )
574 .on_click(cx.listener(move |this, _, _window, cx| {
575 if let Some(content) = acp_tools.read(cx).serialize_observed_messages() {
576 cx.write_to_clipboard(ClipboardItem::new_string(content));
577
578 this.just_copied = true;
579 cx.spawn(async move |this, cx| {
580 cx.background_executor().timer(Duration::from_secs(2)).await;
581 this.update(cx, |this, cx| {
582 this.just_copied = false;
583 cx.notify();
584 })
585 })
586 .detach();
587 }
588 })),
589 )
590 .into_any()
591 }
592}
593
594impl EventEmitter<ToolbarItemEvent> for AcpToolsToolbarItemView {}
595
596impl ToolbarItemView for AcpToolsToolbarItemView {
597 fn set_active_pane_item(
598 &mut self,
599 active_pane_item: Option<&dyn ItemHandle>,
600 _window: &mut Window,
601 cx: &mut Context<Self>,
602 ) -> ToolbarItemLocation {
603 if let Some(item) = active_pane_item
604 && let Some(acp_tools) = item.downcast::<AcpTools>()
605 {
606 self.acp_tools = Some(acp_tools);
607 cx.notify();
608 return ToolbarItemLocation::PrimaryRight;
609 }
610 if self.acp_tools.take().is_some() {
611 cx.notify();
612 }
613 ToolbarItemLocation::Hidden
614 }
615}