1use std::{
2 collections::{HashSet, VecDeque},
3 fmt::Display,
4 sync::Arc,
5};
6
7use agent_client_protocol::schema as acp;
8use collections::HashMap;
9use gpui::{
10 App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState,
11 StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*,
12};
13use language::LanguageRegistry;
14use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
15use project::{AgentId, Project};
16use settings::Settings;
17use theme_settings::ThemeSettings;
18use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*};
19use util::ResultExt as _;
20use workspace::{
21 Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
22};
23
24#[derive(Clone, Copy, PartialEq, Eq)]
25pub enum StreamMessageDirection {
26 Incoming,
27 Outgoing,
28 /// Lines captured from the agent's stderr. These are not part of the
29 /// JSON-RPC protocol, but agents often emit useful diagnostics there.
30 Stderr,
31}
32
33#[derive(Clone)]
34pub enum StreamMessageContent {
35 Request {
36 id: acp::RequestId,
37 method: Arc<str>,
38 params: Option<serde_json::Value>,
39 },
40 Response {
41 id: acp::RequestId,
42 result: Result<Option<serde_json::Value>, acp::Error>,
43 },
44 Notification {
45 method: Arc<str>,
46 params: Option<serde_json::Value>,
47 },
48 /// A raw stderr line from the agent process.
49 Stderr { line: Arc<str> },
50}
51
52#[derive(Clone)]
53pub struct StreamMessage {
54 pub direction: StreamMessageDirection,
55 pub message: StreamMessageContent,
56}
57
58impl StreamMessage {
59 /// Build a `StreamMessage` from a raw line captured off the transport.
60 ///
61 /// For `Stderr`, the line is wrapped as-is (no JSON parsing). For
62 /// `Incoming`/`Outgoing`, the line is parsed as JSON-RPC; returns `None`
63 /// if it doesn't look like a valid JSON-RPC message.
64 pub fn from_raw_line(direction: StreamMessageDirection, line: &str) -> Option<Self> {
65 if direction == StreamMessageDirection::Stderr {
66 return Some(StreamMessage {
67 direction,
68 message: StreamMessageContent::Stderr {
69 line: Arc::from(line),
70 },
71 });
72 }
73
74 let value: serde_json::Value = serde_json::from_str(line).ok()?;
75 let obj = value.as_object()?;
76
77 let parsed_id = obj
78 .get("id")
79 .map(|raw| serde_json::from_value::<acp::RequestId>(raw.clone()));
80
81 let message = if let Some(method) = obj.get("method").and_then(|m| m.as_str()) {
82 match parsed_id {
83 Some(Ok(id)) => StreamMessageContent::Request {
84 id,
85 method: method.into(),
86 params: obj.get("params").cloned(),
87 },
88 Some(Err(err)) => {
89 log::warn!("Skipping JSON-RPC message with unparsable id: {err}");
90 return None;
91 }
92 None => StreamMessageContent::Notification {
93 method: method.into(),
94 params: obj.get("params").cloned(),
95 },
96 }
97 } else if let Some(parsed_id) = parsed_id {
98 let id = match parsed_id {
99 Ok(id) => id,
100 Err(err) => {
101 log::warn!("Skipping JSON-RPC response with unparsable id: {err}");
102 return None;
103 }
104 };
105 if let Some(error) = obj.get("error") {
106 let acp_err =
107 serde_json::from_value::<acp::Error>(error.clone()).unwrap_or_else(|err| {
108 log::warn!("Failed to deserialize ACP error: {err}");
109 acp::Error::internal_error().data(error.to_string())
110 });
111 StreamMessageContent::Response {
112 id,
113 result: Err(acp_err),
114 }
115 } else {
116 StreamMessageContent::Response {
117 id,
118 result: Ok(obj.get("result").cloned()),
119 }
120 }
121 } else {
122 return None;
123 };
124
125 Some(StreamMessage { direction, message })
126 }
127}
128
129actions!(dev, [OpenAcpLogs]);
130
131pub fn init(cx: &mut App) {
132 cx.observe_new(
133 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
134 workspace.register_action(|workspace, _: &OpenAcpLogs, window, cx| {
135 let acp_tools =
136 Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx)));
137 workspace.add_item_to_active_pane(acp_tools, None, true, window, cx);
138 });
139 },
140 )
141 .detach();
142}
143
144struct GlobalAcpConnectionRegistry(Entity<AcpConnectionRegistry>);
145
146impl Global for GlobalAcpConnectionRegistry {}
147
148/// A raw line captured from the transport (or from stderr), tagged with
149/// direction. Deserialization into [`StreamMessage`] happens on the
150/// registry's foreground task so the ring buffer can be replayed to late
151/// subscribers.
152struct RawStreamLine {
153 direction: StreamMessageDirection,
154 line: Arc<str>,
155}
156
157/// Handle to an ACP connection's log tap. Passed back by
158/// [`AcpConnectionRegistry::set_active_connection`] so that the connection
159/// can publish transport and stderr lines without knowing anything about
160/// the logs panel's channel.
161///
162/// Every line is buffered into the registry's ring, so opening the ACP logs
163/// panel after the fact still shows history. The steady-state cost is
164/// negligible compared to the JSON-RPC serialization that already happened
165/// to produce the line.
166#[derive(Clone)]
167pub struct AcpLogTap {
168 sender: smol::channel::Sender<RawStreamLine>,
169}
170
171impl AcpLogTap {
172 fn emit(&self, direction: StreamMessageDirection, line: &str) {
173 self.sender
174 .try_send(RawStreamLine {
175 direction,
176 line: Arc::from(line),
177 })
178 .log_err();
179 }
180
181 /// Record a line read from the agent's stdout.
182 pub fn emit_incoming(&self, line: &str) {
183 self.emit(StreamMessageDirection::Incoming, line);
184 }
185
186 /// Record a line written to the agent's stdin.
187 pub fn emit_outgoing(&self, line: &str) {
188 self.emit(StreamMessageDirection::Outgoing, line);
189 }
190
191 /// Record a line read from the agent's stderr.
192 pub fn emit_stderr(&self, line: &str) {
193 self.emit(StreamMessageDirection::Stderr, line);
194 }
195}
196
197/// Maximum number of messages retained in the registry's backlog.
198///
199/// Mirrors `MAX_STORED_LOG_ENTRIES` in the LSP log store, so that opening the
200/// ACP logs panel after a session has been running for a while still shows
201/// meaningful history.
202const MAX_BACKLOG_MESSAGES: usize = 2000;
203
204#[derive(Default)]
205pub struct AcpConnectionRegistry {
206 active_agent_id: Option<AgentId>,
207 generation: u64,
208 /// Bounded ring buffer of every message observed on the current connection.
209 /// When a new connection is set, this is cleared.
210 backlog: VecDeque<StreamMessage>,
211 subscribers: Vec<smol::channel::Sender<StreamMessage>>,
212 _broadcast_task: Option<Task<()>>,
213}
214
215impl AcpConnectionRegistry {
216 pub fn default_global(cx: &mut App) -> Entity<Self> {
217 if cx.has_global::<GlobalAcpConnectionRegistry>() {
218 cx.global::<GlobalAcpConnectionRegistry>().0.clone()
219 } else {
220 let registry = cx.new(|_cx| AcpConnectionRegistry::default());
221 cx.set_global(GlobalAcpConnectionRegistry(registry.clone()));
222 registry
223 }
224 }
225
226 /// Register a new active connection and return an [`AcpLogTap`] that
227 /// the connection should hand to its transport + stderr readers.
228 ///
229 /// The tap begins capturing immediately so that opening the ACP logs
230 /// panel after something has already gone wrong still shows the
231 /// leading history (up to [`MAX_BACKLOG_MESSAGES`]).
232 pub fn set_active_connection(
233 &mut self,
234 agent_id: AgentId,
235 cx: &mut Context<Self>,
236 ) -> AcpLogTap {
237 let (sender, raw_rx) = smol::channel::unbounded::<RawStreamLine>();
238 let tap = AcpLogTap { sender };
239
240 self.active_agent_id = Some(agent_id);
241 self.generation += 1;
242 self.backlog.clear();
243 self.subscribers.clear();
244
245 self._broadcast_task = Some(cx.spawn(async move |this, cx| {
246 while let Ok(raw) = raw_rx.recv().await {
247 this.update(cx, |this, _cx| {
248 let Some(message) = StreamMessage::from_raw_line(raw.direction, &raw.line)
249 else {
250 return;
251 };
252
253 if this.backlog.len() == MAX_BACKLOG_MESSAGES {
254 this.backlog.pop_front();
255 }
256 this.backlog.push_back(message.clone());
257
258 this.subscribers.retain(|sender| !sender.is_closed());
259 for sender in &this.subscribers {
260 sender.try_send(message.clone()).log_err();
261 }
262 })
263 .log_err();
264 }
265
266 // The transport closed — clear state so observers (e.g. the ACP
267 // logs tab) can transition back to the disconnected state.
268 this.update(cx, |this, cx| {
269 this.active_agent_id = None;
270 this.subscribers.clear();
271 cx.notify();
272 })
273 .log_err();
274 }));
275
276 cx.notify();
277 tap
278 }
279
280 /// Clear the retained message history for the current connection and force
281 /// watchers to resubscribe so their local correlation state is reset too.
282 pub fn clear_messages(&mut self, cx: &mut Context<Self>) {
283 self.backlog.clear();
284 self.generation += 1;
285 self.subscribers.clear();
286 cx.notify();
287 }
288
289 /// Subscribe to messages on the current connection.
290 ///
291 /// Returns the existing backlog (already-observed messages) together with
292 /// a receiver for new messages. The caller is responsible for flushing the
293 /// backlog into its local state before draining the receiver, so that no
294 /// messages are dropped between the snapshot and live subscription.
295 pub fn subscribe(&mut self) -> (Vec<StreamMessage>, smol::channel::Receiver<StreamMessage>) {
296 let backlog = self.backlog.iter().cloned().collect();
297 let (sender, receiver) = smol::channel::unbounded();
298 self.subscribers.push(sender);
299 (backlog, receiver)
300 }
301}
302
303struct AcpTools {
304 project: Entity<Project>,
305 focus_handle: FocusHandle,
306 expanded: HashSet<usize>,
307 watched_connection: Option<WatchedConnection>,
308 connection_registry: Entity<AcpConnectionRegistry>,
309 _subscription: Subscription,
310}
311
312struct WatchedConnection {
313 agent_id: AgentId,
314 generation: u64,
315 messages: Vec<WatchedConnectionMessage>,
316 list_state: ListState,
317 incoming_request_methods: HashMap<acp::RequestId, Arc<str>>,
318 outgoing_request_methods: HashMap<acp::RequestId, Arc<str>>,
319 _task: Task<()>,
320}
321
322impl AcpTools {
323 fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
324 let connection_registry = AcpConnectionRegistry::default_global(cx);
325
326 let subscription = cx.observe(&connection_registry, |this, _, cx| {
327 this.update_connection(cx);
328 cx.notify();
329 });
330
331 let mut this = Self {
332 project,
333 focus_handle: cx.focus_handle(),
334 expanded: HashSet::default(),
335 watched_connection: None,
336 connection_registry,
337 _subscription: subscription,
338 };
339 this.update_connection(cx);
340 this
341 }
342
343 fn update_connection(&mut self, cx: &mut Context<Self>) {
344 let (generation, agent_id) = {
345 let registry = self.connection_registry.read(cx);
346 (registry.generation, registry.active_agent_id.clone())
347 };
348
349 let Some(agent_id) = agent_id else {
350 self.watched_connection = None;
351 self.expanded.clear();
352 return;
353 };
354
355 if let Some(watched) = self.watched_connection.as_ref() {
356 if watched.generation == generation {
357 return;
358 }
359 }
360
361 self.expanded.clear();
362
363 let (backlog, messages_rx) = self
364 .connection_registry
365 .update(cx, |registry, _cx| registry.subscribe());
366
367 let task = cx.spawn(async move |this, cx| {
368 while let Ok(message) = messages_rx.recv().await {
369 this.update(cx, |this, cx| {
370 this.push_stream_message(message, cx);
371 })
372 .log_err();
373 }
374 });
375
376 self.watched_connection = Some(WatchedConnection {
377 agent_id,
378 generation,
379 messages: vec![],
380 list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
381 incoming_request_methods: HashMap::default(),
382 outgoing_request_methods: HashMap::default(),
383 _task: task,
384 });
385
386 for message in backlog {
387 self.push_stream_message(message, cx);
388 }
389 }
390
391 fn push_stream_message(&mut self, stream_message: StreamMessage, cx: &mut Context<Self>) {
392 let Some(connection) = self.watched_connection.as_mut() else {
393 return;
394 };
395 let language_registry = self.project.read(cx).languages().clone();
396 let index = connection.messages.len();
397
398 let (request_id, method, message_type, params) = match stream_message.message {
399 StreamMessageContent::Request { id, method, params } => {
400 let method_map = match stream_message.direction {
401 StreamMessageDirection::Incoming => &mut connection.incoming_request_methods,
402 StreamMessageDirection::Outgoing => &mut connection.outgoing_request_methods,
403 // Stderr lines never carry request/response correlation.
404 StreamMessageDirection::Stderr => return,
405 };
406
407 method_map.insert(id.clone(), method.clone());
408 (Some(id), method.into(), MessageType::Request, Ok(params))
409 }
410 StreamMessageContent::Response { id, result } => {
411 let method_map = match stream_message.direction {
412 StreamMessageDirection::Incoming => &mut connection.outgoing_request_methods,
413 StreamMessageDirection::Outgoing => &mut connection.incoming_request_methods,
414 StreamMessageDirection::Stderr => return,
415 };
416
417 if let Some(method) = method_map.remove(&id) {
418 (Some(id), method.into(), MessageType::Response, result)
419 } else {
420 (
421 Some(id),
422 "[unrecognized response]".into(),
423 MessageType::Response,
424 result,
425 )
426 }
427 }
428 StreamMessageContent::Notification { method, params } => {
429 (None, method.into(), MessageType::Notification, Ok(params))
430 }
431 StreamMessageContent::Stderr { line } => {
432 // Stderr is rendered as plain text inline with JSON-RPC traffic,
433 // using `stderr` as the pseudo-method name so it shows up in the
434 // header the same way real methods do.
435 (
436 None,
437 "stderr".into(),
438 MessageType::Stderr,
439 Ok(Some(serde_json::Value::String(line.to_string()))),
440 )
441 }
442 };
443
444 let message = WatchedConnectionMessage {
445 name: method,
446 message_type,
447 request_id,
448 direction: stream_message.direction,
449 collapsed_params_md: match params.as_ref() {
450 Ok(params) => params
451 .as_ref()
452 .map(|params| collapsed_params_md(params, &language_registry, cx)),
453 Err(err) => {
454 if let Ok(err) = &serde_json::to_value(err) {
455 Some(collapsed_params_md(&err, &language_registry, cx))
456 } else {
457 None
458 }
459 }
460 },
461
462 expanded_params_md: None,
463 params,
464 };
465
466 connection.messages.push(message);
467 connection.list_state.splice(index..index, 1);
468 cx.notify();
469 }
470
471 fn serialize_observed_messages(&self) -> Option<String> {
472 let connection = self.watched_connection.as_ref()?;
473
474 let messages: Vec<serde_json::Value> = connection
475 .messages
476 .iter()
477 .filter_map(|message| {
478 let params = match &message.params {
479 Ok(Some(params)) => params.clone(),
480 Ok(None) => serde_json::Value::Null,
481 Err(err) => serde_json::to_value(err).ok()?,
482 };
483 Some(serde_json::json!({
484 "_direction": match message.direction {
485 StreamMessageDirection::Incoming => "incoming",
486 StreamMessageDirection::Outgoing => "outgoing",
487 StreamMessageDirection::Stderr => "stderr",
488 },
489 "_type": message.message_type.to_string().to_lowercase(),
490 "id": message.request_id,
491 "method": message.name.to_string(),
492 "params": params,
493 }))
494 })
495 .collect();
496
497 serde_json::to_string_pretty(&messages).ok()
498 }
499
500 fn clear_messages(&mut self, cx: &mut Context<Self>) {
501 if let Some(connection) = self.watched_connection.as_mut() {
502 connection.messages.clear();
503 connection.list_state.reset(0);
504 connection.incoming_request_methods.clear();
505 connection.outgoing_request_methods.clear();
506 self.expanded.clear();
507 cx.notify();
508 }
509 }
510
511 fn render_message(
512 &mut self,
513 index: usize,
514 window: &mut Window,
515 cx: &mut Context<Self>,
516 ) -> AnyElement {
517 let Some(connection) = self.watched_connection.as_ref() else {
518 return Empty.into_any();
519 };
520
521 let Some(message) = connection.messages.get(index) else {
522 return Empty.into_any();
523 };
524
525 let base_size = TextSize::Editor.rems(cx);
526
527 let theme_settings = ThemeSettings::get_global(cx);
528 let text_style = window.text_style();
529
530 let colors = cx.theme().colors();
531 let expanded = self.expanded.contains(&index);
532
533 v_flex()
534 .id(index)
535 .group("message")
536 .font_buffer(cx)
537 .w_full()
538 .py_3()
539 .pl_4()
540 .pr_5()
541 .gap_2()
542 .items_start()
543 .text_size(base_size)
544 .border_color(colors.border)
545 .border_b_1()
546 .hover(|this| this.bg(colors.element_background.opacity(0.5)))
547 .child(
548 h_flex()
549 .id(("acp-log-message-header", index))
550 .w_full()
551 .gap_2()
552 .flex_shrink_0()
553 .cursor_pointer()
554 .on_click(cx.listener(move |this, _, _, cx| {
555 if this.expanded.contains(&index) {
556 this.expanded.remove(&index);
557 } else {
558 this.expanded.insert(index);
559 let Some(connection) = &mut this.watched_connection else {
560 return;
561 };
562 let Some(message) = connection.messages.get_mut(index) else {
563 return;
564 };
565 message.expanded(this.project.read(cx).languages().clone(), cx);
566 connection.list_state.scroll_to_reveal_item(index);
567 }
568 cx.notify()
569 }))
570 .child(match message.direction {
571 StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown)
572 .color(Color::Error)
573 .size(IconSize::Small),
574 StreamMessageDirection::Outgoing => Icon::new(IconName::ArrowUp)
575 .color(Color::Success)
576 .size(IconSize::Small),
577 StreamMessageDirection::Stderr => Icon::new(IconName::Warning)
578 .color(Color::Warning)
579 .size(IconSize::Small),
580 })
581 .child(
582 Label::new(message.name.clone())
583 .buffer_font(cx)
584 .color(Color::Muted),
585 )
586 .child(div().flex_1())
587 .child(
588 div()
589 .child(ui::Chip::new(message.message_type.to_string()))
590 .visible_on_hover("message"),
591 )
592 .children(
593 message
594 .request_id
595 .as_ref()
596 .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))),
597 ),
598 )
599 // I'm aware using markdown is a hack. Trying to get something working for the demo.
600 // Will clean up soon!
601 .when_some(
602 if expanded {
603 message.expanded_params_md.clone()
604 } else {
605 message.collapsed_params_md.clone()
606 },
607 |this, params| {
608 this.child(
609 div().pl_6().w_full().child(
610 MarkdownElement::new(
611 params,
612 MarkdownStyle {
613 base_text_style: text_style,
614 selection_background_color: colors.element_selection_background,
615 syntax: cx.theme().syntax().clone(),
616 code_block_overflow_x_scroll: true,
617 code_block: StyleRefinement {
618 text: TextStyleRefinement {
619 font_family: Some(
620 theme_settings.buffer_font.family.clone(),
621 ),
622 font_size: Some((base_size * 0.8).into()),
623 ..Default::default()
624 },
625 ..Default::default()
626 },
627 ..Default::default()
628 },
629 )
630 .code_block_renderer(
631 CodeBlockRenderer::Default {
632 copy_button_visibility: if expanded {
633 CopyButtonVisibility::VisibleOnHover
634 } else {
635 CopyButtonVisibility::Hidden
636 },
637 border: false,
638 },
639 ),
640 ),
641 )
642 },
643 )
644 .into_any()
645 }
646}
647
648struct WatchedConnectionMessage {
649 name: SharedString,
650 request_id: Option<acp::RequestId>,
651 direction: StreamMessageDirection,
652 message_type: MessageType,
653 params: Result<Option<serde_json::Value>, acp::Error>,
654 collapsed_params_md: Option<Entity<Markdown>>,
655 expanded_params_md: Option<Entity<Markdown>>,
656}
657
658impl WatchedConnectionMessage {
659 fn expanded(&mut self, language_registry: Arc<LanguageRegistry>, cx: &mut App) {
660 let params_md = match &self.params {
661 Ok(Some(params)) => Some(expanded_params_md(params, &language_registry, cx)),
662 Err(err) => {
663 if let Some(err) = &serde_json::to_value(err).log_err() {
664 Some(expanded_params_md(&err, &language_registry, cx))
665 } else {
666 None
667 }
668 }
669 _ => None,
670 };
671 self.expanded_params_md = params_md;
672 }
673}
674
675fn collapsed_params_md(
676 params: &serde_json::Value,
677 language_registry: &Arc<LanguageRegistry>,
678 cx: &mut App,
679) -> Entity<Markdown> {
680 let params_json = serde_json::to_string(params).unwrap_or_default();
681 let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4);
682
683 for ch in params_json.chars() {
684 match ch {
685 '{' => spaced_out_json.push_str("{ "),
686 '}' => spaced_out_json.push_str(" }"),
687 ':' => spaced_out_json.push_str(": "),
688 ',' => spaced_out_json.push_str(", "),
689 c => spaced_out_json.push(c),
690 }
691 }
692
693 let params_md = format!("```json\n{}\n```", spaced_out_json);
694 cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
695}
696
697fn expanded_params_md(
698 params: &serde_json::Value,
699 language_registry: &Arc<LanguageRegistry>,
700 cx: &mut App,
701) -> Entity<Markdown> {
702 let params_json = serde_json::to_string_pretty(params).unwrap_or_default();
703 let params_md = format!("```json\n{}\n```", params_json);
704 cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx))
705}
706
707enum MessageType {
708 Request,
709 Response,
710 Notification,
711 Stderr,
712}
713
714impl Display for MessageType {
715 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716 match self {
717 MessageType::Request => write!(f, "Request"),
718 MessageType::Response => write!(f, "Response"),
719 MessageType::Notification => write!(f, "Notification"),
720 MessageType::Stderr => write!(f, "Stderr"),
721 }
722 }
723}
724
725enum AcpToolsEvent {}
726
727impl EventEmitter<AcpToolsEvent> for AcpTools {}
728
729impl Item for AcpTools {
730 type Event = AcpToolsEvent;
731
732 fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
733 format!(
734 "ACP: {}",
735 self.watched_connection
736 .as_ref()
737 .map_or("Disconnected", |connection| connection.agent_id.0.as_ref())
738 )
739 .into()
740 }
741
742 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
743 Some(ui::Icon::new(IconName::Thread))
744 }
745}
746
747impl Focusable for AcpTools {
748 fn focus_handle(&self, _cx: &App) -> FocusHandle {
749 self.focus_handle.clone()
750 }
751}
752
753impl Render for AcpTools {
754 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
755 v_flex()
756 .track_focus(&self.focus_handle)
757 .size_full()
758 .bg(cx.theme().colors().editor_background)
759 .child(match self.watched_connection.as_ref() {
760 Some(connection) => {
761 if connection.messages.is_empty() {
762 h_flex()
763 .size_full()
764 .justify_center()
765 .items_center()
766 .child("No messages recorded yet")
767 .into_any()
768 } else {
769 div()
770 .size_full()
771 .flex_grow()
772 .child(
773 list(
774 connection.list_state.clone(),
775 cx.processor(Self::render_message),
776 )
777 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
778 .size_full(),
779 )
780 .vertical_scrollbar_for(&connection.list_state, window, cx)
781 .into_any()
782 }
783 }
784 None => h_flex()
785 .size_full()
786 .justify_center()
787 .items_center()
788 .child("No active connection")
789 .into_any(),
790 })
791 }
792}
793
794pub struct AcpToolsToolbarItemView {
795 acp_tools: Option<Entity<AcpTools>>,
796}
797
798impl AcpToolsToolbarItemView {
799 pub fn new() -> Self {
800 Self { acp_tools: None }
801 }
802}
803
804impl Render for AcpToolsToolbarItemView {
805 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
806 let Some(acp_tools) = self.acp_tools.as_ref() else {
807 return Empty.into_any_element();
808 };
809
810 let acp_tools = acp_tools.clone();
811 let connection_registry = acp_tools.read(cx).connection_registry.clone();
812 let has_messages = acp_tools
813 .read(cx)
814 .watched_connection
815 .as_ref()
816 .is_some_and(|connection| !connection.messages.is_empty());
817
818 h_flex()
819 .gap_2()
820 .child({
821 let message = acp_tools
822 .read(cx)
823 .serialize_observed_messages()
824 .unwrap_or_default();
825
826 CopyButton::new("copy-all-messages", message)
827 .tooltip_label("Copy All Messages")
828 .disabled(!has_messages)
829 })
830 .child(
831 IconButton::new("clear_messages", IconName::Trash)
832 .icon_size(IconSize::Small)
833 .tooltip(Tooltip::text("Clear Messages"))
834 .disabled(!has_messages)
835 .on_click(cx.listener(move |_this, _, _window, cx| {
836 connection_registry.update(cx, |registry, cx| {
837 registry.clear_messages(cx);
838 });
839 acp_tools.update(cx, |acp_tools, cx| {
840 acp_tools.clear_messages(cx);
841 });
842 })),
843 )
844 .into_any()
845 }
846}
847
848impl EventEmitter<ToolbarItemEvent> for AcpToolsToolbarItemView {}
849
850impl ToolbarItemView for AcpToolsToolbarItemView {
851 fn set_active_pane_item(
852 &mut self,
853 active_pane_item: Option<&dyn ItemHandle>,
854 _window: &mut Window,
855 cx: &mut Context<Self>,
856 ) -> ToolbarItemLocation {
857 if let Some(item) = active_pane_item
858 && let Some(acp_tools) = item.downcast::<AcpTools>()
859 {
860 self.acp_tools = Some(acp_tools);
861 cx.notify();
862 return ToolbarItemLocation::PrimaryRight;
863 }
864 if self.acp_tools.take().is_some() {
865 cx.notify();
866 }
867 ToolbarItemLocation::Hidden
868 }
869}