claude_code_acp/terminal/
client.rs

1//! Terminal API client
2//!
3//! Provides a client interface for sending Terminal API requests to the ACP Client.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Instant;
8
9use sacp::JrConnectionCx;
10use sacp::link::AgentToClient;
11use sacp::schema::{
12    CreateTerminalRequest, CreateTerminalResponse, EnvVariable, KillTerminalCommandRequest,
13    KillTerminalCommandResponse, ReleaseTerminalRequest, ReleaseTerminalResponse, SessionId,
14    TerminalId, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest,
15    WaitForTerminalExitResponse,
16};
17use tracing::instrument;
18
19use crate::types::AgentError;
20
21/// Terminal API client for sending terminal requests to the ACP Client
22///
23/// The Client (editor like Zed) manages the actual PTY, and this client
24/// sends requests through the ACP protocol to create, manage, and interact
25/// with terminals.
26#[derive(Debug, Clone)]
27pub struct TerminalClient {
28    /// Connection context for sending requests
29    connection_cx: JrConnectionCx<AgentToClient>,
30    /// Session ID for this client
31    session_id: SessionId,
32}
33
34impl TerminalClient {
35    /// Create a new Terminal API client
36    pub fn new(
37        connection_cx: JrConnectionCx<AgentToClient>,
38        session_id: impl Into<SessionId>,
39    ) -> Self {
40        Self {
41            connection_cx,
42            session_id: session_id.into(),
43        }
44    }
45
46    /// Create a new terminal and execute a command
47    ///
48    /// Returns a `TerminalId` that can be used with other terminal methods.
49    /// The terminal will execute the specified command and capture output.
50    ///
51    /// # Arguments
52    ///
53    /// * `command` - The command to execute
54    /// * `args` - Command arguments
55    /// * `cwd` - Optional working directory (uses session cwd if not specified)
56    /// * `output_byte_limit` - Optional limit on output bytes to retain
57    #[instrument(
58        name = "terminal_create",
59        skip(self, command, args, cwd),
60        fields(
61            session_id = %self.session_id.0,
62            args_count = args.len(),
63            has_cwd = cwd.is_some(),
64        )
65    )]
66    pub async fn create(
67        &self,
68        command: impl Into<String>,
69        args: Vec<String>,
70        cwd: Option<PathBuf>,
71        output_byte_limit: Option<u64>,
72    ) -> Result<TerminalId, AgentError> {
73        let start_time = Instant::now();
74        let cmd: String = command.into();
75
76        tracing::info!(
77            command = %cmd,
78            args = ?args,
79            cwd = ?cwd,
80            output_byte_limit = ?output_byte_limit,
81            "Creating terminal and executing command"
82        );
83
84        let mut request = CreateTerminalRequest::new(self.session_id.clone(), cmd.clone());
85        request = request.args(args.clone());
86
87        // Set CLAUDECODE environment variable (required by some clients like Zed)
88        request = request.env(vec![EnvVariable::new("CLAUDECODE", "1")]);
89
90        if let Some(cwd_path) = cwd.clone() {
91            request = request.cwd(cwd_path);
92        }
93
94        if let Some(limit) = output_byte_limit {
95            request = request.output_byte_limit(limit);
96        }
97
98        tracing::debug!("Sending terminal/create request to ACP client");
99
100        let response: CreateTerminalResponse = self
101            .connection_cx
102            .send_request(request)
103            .block_task()
104            .await
105            .map_err(|e| {
106                let elapsed = start_time.elapsed();
107                tracing::error!(
108                    session_id = %self.session_id.0,
109                    command = %cmd,
110                    error = %e,
111                    error_type = %std::any::type_name::<sacp::Error>(),
112                    elapsed_ms = elapsed.as_millis(),
113                    "Terminal create request failed"
114                );
115                AgentError::Internal(format!("Terminal create failed: {}", e))
116            })?;
117
118        let elapsed = start_time.elapsed();
119        tracing::info!(
120            terminal_id = %response.terminal_id.0,
121            command = %cmd,
122            elapsed_ms = elapsed.as_millis(),
123            "Terminal created successfully"
124        );
125
126        Ok(response.terminal_id)
127    }
128
129    /// Get the current output and status of a terminal
130    ///
131    /// Returns the output captured so far and the exit status if completed.
132    #[instrument(
133        name = "terminal_output",
134        skip(self, terminal_id),
135        fields(session_id = %self.session_id.0)
136    )]
137    pub async fn output(
138        &self,
139        terminal_id: impl Into<TerminalId>,
140    ) -> Result<TerminalOutputResponse, AgentError> {
141        let start_time = Instant::now();
142        let tid: TerminalId = terminal_id.into();
143
144        tracing::debug!(
145            terminal_id = %tid.0,
146            "Getting terminal output"
147        );
148
149        let request = TerminalOutputRequest::new(self.session_id.clone(), tid.clone());
150
151        let response = self
152            .connection_cx
153            .send_request(request)
154            .block_task()
155            .await
156            .map_err(|e| {
157                let elapsed = start_time.elapsed();
158                tracing::error!(
159                    terminal_id = %tid.0,
160                    error = %e,
161                    elapsed_ms = elapsed.as_millis(),
162                    "Terminal output request failed"
163                );
164                AgentError::Internal(format!("Terminal output failed: {}", e))
165            })?;
166
167        let elapsed = start_time.elapsed();
168        tracing::debug!(
169            terminal_id = %tid.0,
170            elapsed_ms = elapsed.as_millis(),
171            output_len = response.output.len(),
172            exit_status = ?response.exit_status,
173            "Terminal output retrieved"
174        );
175
176        Ok(response)
177    }
178
179    /// Wait for a terminal command to exit
180    ///
181    /// Blocks until the command completes and returns the exit status.
182    #[instrument(
183        name = "terminal_wait_for_exit",
184        skip(self, terminal_id),
185        fields(session_id = %self.session_id.0)
186    )]
187    pub async fn wait_for_exit(
188        &self,
189        terminal_id: impl Into<TerminalId>,
190    ) -> Result<WaitForTerminalExitResponse, AgentError> {
191        let start_time = Instant::now();
192        let tid: TerminalId = terminal_id.into();
193
194        tracing::info!(
195            terminal_id = %tid.0,
196            "Waiting for terminal command to exit"
197        );
198
199        let request = WaitForTerminalExitRequest::new(self.session_id.clone(), tid.clone());
200
201        let response = self
202            .connection_cx
203            .send_request(request)
204            .block_task()
205            .await
206            .map_err(|e| {
207                let elapsed = start_time.elapsed();
208                tracing::error!(
209                    terminal_id = %tid.0,
210                    error = %e,
211                    elapsed_ms = elapsed.as_millis(),
212                    "Terminal wait_for_exit failed"
213                );
214                AgentError::Internal(format!("Terminal wait_for_exit failed: {}", e))
215            })?;
216
217        let elapsed = start_time.elapsed();
218        tracing::info!(
219            terminal_id = %tid.0,
220            elapsed_ms = elapsed.as_millis(),
221            exit_status = ?response.exit_status,
222            "Terminal command exited"
223        );
224
225        Ok(response)
226    }
227
228    /// Kill a terminal command
229    ///
230    /// Sends SIGTERM to terminate the command. The terminal remains valid
231    /// and can be queried for output or released.
232    #[instrument(
233        name = "terminal_kill",
234        skip(self, terminal_id),
235        fields(session_id = %self.session_id.0)
236    )]
237    pub async fn kill(
238        &self,
239        terminal_id: impl Into<TerminalId>,
240    ) -> Result<KillTerminalCommandResponse, AgentError> {
241        let start_time = Instant::now();
242        let tid: TerminalId = terminal_id.into();
243
244        tracing::info!(
245            terminal_id = %tid.0,
246            "Killing terminal command"
247        );
248
249        let request = KillTerminalCommandRequest::new(self.session_id.clone(), tid.clone());
250
251        let response = self
252            .connection_cx
253            .send_request(request)
254            .block_task()
255            .await
256            .map_err(|e| {
257                let elapsed = start_time.elapsed();
258                tracing::error!(
259                    terminal_id = %tid.0,
260                    error = %e,
261                    elapsed_ms = elapsed.as_millis(),
262                    "Terminal kill failed"
263                );
264                AgentError::Internal(format!("Terminal kill failed: {}", e))
265            })?;
266
267        let elapsed = start_time.elapsed();
268        tracing::info!(
269            terminal_id = %tid.0,
270            elapsed_ms = elapsed.as_millis(),
271            "Terminal command killed"
272        );
273
274        Ok(response)
275    }
276
277    /// Release a terminal and free its resources
278    ///
279    /// After release, the `TerminalId` can no longer be used.
280    /// Any unretrieved output will be lost.
281    #[instrument(
282        name = "terminal_release",
283        skip(self, terminal_id),
284        fields(session_id = %self.session_id.0)
285    )]
286    pub async fn release(
287        &self,
288        terminal_id: impl Into<TerminalId>,
289    ) -> Result<ReleaseTerminalResponse, AgentError> {
290        let start_time = Instant::now();
291        let tid: TerminalId = terminal_id.into();
292
293        tracing::debug!(
294            terminal_id = %tid.0,
295            "Releasing terminal"
296        );
297
298        let request = ReleaseTerminalRequest::new(self.session_id.clone(), tid.clone());
299
300        let response = self
301            .connection_cx
302            .send_request(request)
303            .block_task()
304            .await
305            .map_err(|e| {
306                let elapsed = start_time.elapsed();
307                tracing::error!(
308                    terminal_id = %tid.0,
309                    error = %e,
310                    elapsed_ms = elapsed.as_millis(),
311                    "Terminal release failed"
312                );
313                AgentError::Internal(format!("Terminal release failed: {}", e))
314            })?;
315
316        let elapsed = start_time.elapsed();
317        tracing::debug!(
318            terminal_id = %tid.0,
319            elapsed_ms = elapsed.as_millis(),
320            "Terminal released"
321        );
322
323        Ok(response)
324    }
325
326    /// Get the session ID
327    pub fn session_id(&self) -> &SessionId {
328        &self.session_id
329    }
330
331    /// Create an Arc-wrapped client for sharing
332    pub fn into_arc(self) -> Arc<Self> {
333        Arc::new(self)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    #[test]
340    fn test_terminal_client_session_id() {
341        // We can't easily test without a real connection, but we can verify the struct compiles
342        // and the session_id method works (would need mock connection for full test)
343    }
344}