Skip to main content

acp_cli/client/
mod.rs

1pub mod permissions;
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5
6use agent_client_protocol as acp;
7use tokio::sync::{mpsc, oneshot};
8
9use crate::bridge::events::{
10    BridgeEvent, PermissionKind, PermissionOption, PermissionOutcome, ToolCallInfo,
11};
12
13/// ACP Client implementation that bridges agent protocol events
14/// into the internal BridgeEvent channel.
15///
16/// Handles permission requests by forwarding them through a oneshot channel
17/// to the main event loop, and converts session notifications (text chunks,
18/// tool calls) into corresponding BridgeEvent variants.
19pub struct BridgedAcpClient {
20    pub evt_tx: mpsc::UnboundedSender<BridgeEvent>,
21    /// Maps tool_call_id → (title, is_read) for in-flight tool calls.
22    /// Populated on ToolCall (start), consumed on ToolCallUpdate with Completed/Failed.
23    tool_call_state: RefCell<HashMap<String, (String, bool)>>,
24}
25
26impl BridgedAcpClient {
27    pub fn new(evt_tx: mpsc::UnboundedSender<BridgeEvent>) -> Self {
28        Self {
29            evt_tx,
30            tool_call_state: RefCell::new(HashMap::new()),
31        }
32    }
33}
34
35#[async_trait::async_trait(?Send)]
36impl acp::Client for BridgedAcpClient {
37    async fn request_permission(
38        &self,
39        args: acp::RequestPermissionRequest,
40    ) -> acp::Result<acp::RequestPermissionResponse> {
41        let tool = ToolCallInfo {
42            name: args.tool_call.fields.title.clone().unwrap_or_default(),
43            description: None,
44        };
45
46        let options: Vec<PermissionOption> = args
47            .options
48            .iter()
49            .map(|o| PermissionOption {
50                option_id: o.option_id.0.to_string(),
51                name: o.name.clone(),
52                kind: match o.kind {
53                    acp::PermissionOptionKind::AllowOnce
54                    | acp::PermissionOptionKind::AllowAlways => PermissionKind::Allow,
55                    _ => PermissionKind::Deny,
56                },
57            })
58            .collect();
59
60        let (reply_tx, reply_rx) = oneshot::channel();
61        let _ = self.evt_tx.send(BridgeEvent::PermissionRequest {
62            tool,
63            options,
64            reply: reply_tx,
65        });
66
67        let outcome = reply_rx.await.unwrap_or(PermissionOutcome::Cancelled);
68
69        let acp_outcome = match outcome {
70            PermissionOutcome::Selected { option_id } => acp::RequestPermissionOutcome::Selected(
71                acp::SelectedPermissionOutcome::new(option_id),
72            ),
73            PermissionOutcome::Cancelled => acp::RequestPermissionOutcome::Cancelled,
74        };
75
76        Ok(acp::RequestPermissionResponse::new(acp_outcome))
77    }
78
79    async fn session_notification(&self, args: acp::SessionNotification) -> acp::Result<()> {
80        match args.update {
81            acp::SessionUpdate::AgentMessageChunk(chunk) => {
82                if let acp::ContentBlock::Text(text_content) = chunk.content {
83                    let _ = self.evt_tx.send(BridgeEvent::TextChunk {
84                        text: text_content.text,
85                    });
86                }
87            }
88            acp::SessionUpdate::ToolCall(tool_call) => {
89                // A new tool call has been announced — emit ToolUse for the spinner
90                // and record (title, is_read) keyed by tool_call_id for later.
91                let is_read = matches!(tool_call.kind, acp::ToolKind::Read);
92                let id = tool_call.tool_call_id.0.to_string();
93                let title = tool_call.title.clone();
94                self.tool_call_state
95                    .borrow_mut()
96                    .insert(id, (title.clone(), is_read));
97                let _ = self.evt_tx.send(BridgeEvent::ToolUse { name: title });
98            }
99            acp::SessionUpdate::ToolCallUpdate(update) => {
100                // Only act when the tool has finished (Completed or Failed).
101                let done = matches!(
102                    update.fields.status,
103                    Some(acp::ToolCallStatus::Completed) | Some(acp::ToolCallStatus::Failed)
104                );
105                if done {
106                    let id = update.tool_call_id.0.to_string();
107                    let (title, is_read) = self
108                        .tool_call_state
109                        .borrow_mut()
110                        .remove(&id)
111                        .unwrap_or_else(|| {
112                            // ToolCallUpdate arrived for an ID we never saw a ToolCall for.
113                            // Emit a result with an empty name rather than panicking.
114                            eprintln!("[acp-cli] warning: ToolCallUpdate for unknown id={id}");
115                            (String::new(), false)
116                        });
117                    let output = update
118                        .fields
119                        .content
120                        .as_deref()
121                        .map(extract_text_output)
122                        .unwrap_or_default();
123                    let _ = self.evt_tx.send(BridgeEvent::ToolResult {
124                        name: title,
125                        output,
126                        is_read,
127                    });
128                }
129            }
130            _ => {
131                // Ignore other session update variants
132            }
133        }
134        Ok(())
135    }
136}
137
138/// Extract plain-text output from a slice of `ToolCallContent` items.
139///
140/// Only `Text` content blocks are collected; images, audio, and resource links
141/// are intentionally dropped — the CLI renders to a terminal and has no way to
142/// display binary content. If future agents produce non-text tool output that
143/// matters for display, this function is the place to extend.
144fn extract_text_output(content: &[acp::ToolCallContent]) -> String {
145    content
146        .iter()
147        .filter_map(|c| {
148            if let acp::ToolCallContent::Content(block) = c
149                && let acp::ContentBlock::Text(text) = &block.content
150            {
151                return Some(text.text.clone());
152            }
153            None
154        })
155        .collect::<Vec<_>>()
156        .join("")
157}