Skip to main content

claude_code_cli_acp/pty/
session.rs

1use std::ffi::OsString;
2use std::io::{Read, Write};
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5use std::thread::{self, JoinHandle};
6
7use agent_client_protocol::schema::McpServer;
8use anyhow::{Context, anyhow};
9use portable_pty::{Child, CommandBuilder, PtySize, native_pty_system};
10use serde_json::{Value, json};
11use uuid::Uuid;
12
13use crate::pty::input;
14use crate::terminal::{
15    recognizers::{PermissionDecision, PermissionDialog, recognize_permission_dialog},
16    screen::TerminalScreen,
17};
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ClaudePtyConfig {
21    pub executable: PathBuf,
22    pub cwd: PathBuf,
23    pub session_id: String,
24    pub model: Option<String>,
25    pub permission_mode: Option<String>,
26    pub setting_sources: Option<String>,
27    pub resume: Option<String>,
28    pub continue_last: bool,
29    pub mcp_servers: Vec<McpServer>,
30    pub extra_args: Vec<OsString>,
31    pub rows: u16,
32    pub cols: u16,
33}
34
35impl Default for ClaudePtyConfig {
36    fn default() -> Self {
37        Self {
38            executable: PathBuf::from("claude"),
39            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
40            session_id: uuid::Uuid::new_v4().to_string(),
41            model: None,
42            permission_mode: None,
43            setting_sources: None,
44            resume: None,
45            continue_last: false,
46            mcp_servers: Vec::new(),
47            extra_args: Vec::new(),
48            rows: 24,
49            cols: 80,
50        }
51    }
52}
53
54impl ClaudePtyConfig {
55    pub fn launch_argv(&self) -> anyhow::Result<Vec<OsString>> {
56        let mut argv = Vec::new();
57        argv.push(self.executable.clone().into_os_string());
58        argv.push("--session-id".into());
59        argv.push(self.session_id.clone().into());
60        if let Some(model) = &self.model {
61            argv.push("--model".into());
62            argv.push(model.into());
63        }
64        if let Some(permission_mode) = &self.permission_mode {
65            argv.push("--permission-mode".into());
66            argv.push(permission_mode.into());
67        }
68        if let Some(setting_sources) = &self.setting_sources {
69            argv.push("--setting-sources".into());
70            argv.push(setting_sources.into());
71        }
72        if let Some(session) = &self.resume {
73            argv.push("--resume".into());
74            argv.push(session.into());
75        }
76        if self.continue_last {
77            argv.push("--continue".into());
78        }
79        argv.extend(self.extra_args.iter().cloned());
80        reject_print_mode_args(argv.iter().skip(1))?;
81        Ok(argv)
82    }
83}
84
85pub struct ClaudePtySession {
86    child: Box<dyn Child + Send + Sync>,
87    writer: Box<dyn Write + Send>,
88    screen: Arc<Mutex<TerminalScreen>>,
89    reader_thread: Option<JoinHandle<()>>,
90    executable: PathBuf,
91    cwd: PathBuf,
92    session_id: String,
93    model: Option<String>,
94    permission_mode: Option<String>,
95    generated_mcp_config: Option<PathBuf>,
96}
97
98impl ClaudePtySession {
99    pub fn spawn(config: ClaudePtyConfig) -> anyhow::Result<Self> {
100        let generated_mcp_config = write_mcp_config_file(&config)?;
101        let mut argv = config.launch_argv()?;
102        if let Some(path) = &generated_mcp_config {
103            argv.push("--mcp-config".into());
104            argv.push(path.into());
105            argv.push("--strict-mcp-config".into());
106        }
107        let mut command = CommandBuilder::new(&argv[0]);
108        command.args(argv.iter().skip(1));
109        command.cwd(&config.cwd);
110        command.env("CLAUDE_CODE_NO_FLICKER", "1");
111        command.env("CLAUDE_CODE_DISABLE_MOUSE", "1");
112        command.env("CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION", "false");
113
114        let pty_system = native_pty_system();
115        let pair = pty_system
116            .openpty(PtySize {
117                rows: config.rows,
118                cols: config.cols,
119                pixel_width: 0,
120                pixel_height: 0,
121            })
122            .context("open pty")?;
123        let child = pair
124            .slave
125            .spawn_command(command)
126            .context("spawn claude pty")?;
127        let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
128        let writer = pair.master.take_writer().context("take pty writer")?;
129        drop(pair.slave);
130
131        let screen = Arc::new(Mutex::new(TerminalScreen::new(config.rows, config.cols)));
132        let reader_screen = Arc::clone(&screen);
133        let reader_thread = thread::spawn(move || {
134            let mut buffer = [0_u8; 8192];
135            loop {
136                match reader.read(&mut buffer) {
137                    Ok(0) => break,
138                    Ok(read) => {
139                        if let Ok(mut screen) = reader_screen.lock() {
140                            screen.process(&buffer[..read]);
141                        } else {
142                            break;
143                        }
144                    }
145                    Err(_) => break,
146                }
147            }
148        });
149
150        Ok(Self {
151            child,
152            writer,
153            screen,
154            reader_thread: Some(reader_thread),
155            executable: config.executable,
156            cwd: config.cwd,
157            session_id: config.session_id,
158            model: config.model,
159            permission_mode: config.permission_mode,
160            generated_mcp_config,
161        })
162    }
163
164    pub fn write_bytes(&mut self, bytes: &[u8]) -> anyhow::Result<()> {
165        self.writer.write_all(bytes).context("write pty bytes")?;
166        self.writer.flush().context("flush pty bytes")
167    }
168
169    pub fn submit_prompt(&mut self, prompt: &str) -> anyhow::Result<()> {
170        self.write_bytes(prompt.as_bytes())?;
171        thread::sleep(std::time::Duration::from_millis(100));
172        self.write_bytes(b"\r")
173    }
174
175    pub fn send_exit(&mut self) -> anyhow::Result<()> {
176        self.write_bytes(&input::slash_command("exit"))
177    }
178
179    pub fn send_interrupt(&mut self) -> anyhow::Result<()> {
180        self.write_bytes(&input::ctrl_c())
181    }
182
183    pub fn terminate(&mut self) -> anyhow::Result<()> {
184        if self.child.try_wait().context("poll pty child")?.is_none() {
185            self.child.kill().context("kill pty child")?;
186        }
187        if let Some(path) = self.generated_mcp_config.take() {
188            drop(std::fs::remove_file(path));
189        }
190        Ok(())
191    }
192
193    pub fn screen_snapshot(&self) -> anyhow::Result<String> {
194        Ok(self
195            .screen
196            .lock()
197            .map(|screen| screen.text())
198            .unwrap_or_else(|_| String::new()))
199    }
200
201    pub fn is_idle(&self) -> bool {
202        self.screen_snapshot()
203            .map(|text| crate::terminal::recognizers::recognize_idle(&text))
204            .unwrap_or(false)
205    }
206
207    pub fn permission_dialog(&self) -> anyhow::Result<Option<PermissionDialog>> {
208        Ok(recognize_permission_dialog(&self.screen_snapshot()?))
209    }
210
211    pub fn select_permission(&mut self, decision: PermissionDecision) -> anyhow::Result<bool> {
212        let Some(dialog) = self.permission_dialog()? else {
213            return Ok(false);
214        };
215        let Some(bytes) = input::permission_choice(&dialog, decision) else {
216            return Ok(false);
217        };
218        self.write_bytes(&bytes)?;
219        Ok(true)
220    }
221
222    pub fn detach_for_user(&mut self) -> anyhow::Result<()> {
223        self.terminate()?;
224
225        let mut command = std::process::Command::new(&self.executable);
226        command
227            .arg("--resume")
228            .arg(&self.session_id)
229            .current_dir(&self.cwd);
230        if let Some(model) = &self.model {
231            command.arg("--model").arg(model);
232        }
233        if let Some(permission_mode) = &self.permission_mode {
234            command.arg("--permission-mode").arg(permission_mode);
235        }
236        let status = command
237            .status()
238            .context("attach user to resumed Claude session")?;
239        if status.success() {
240            Ok(())
241        } else {
242            Err(anyhow!(
243                "attached Claude session exited with status {status}"
244            ))
245        }
246    }
247}
248
249fn write_mcp_config_file(config: &ClaudePtyConfig) -> anyhow::Result<Option<PathBuf>> {
250    if config.mcp_servers.is_empty() {
251        return Ok(None);
252    }
253    let path = std::env::temp_dir().join(format!(
254        "claude-code-cli-acp-mcp-{}-{}.json",
255        config.session_id,
256        Uuid::new_v4()
257    ));
258    let body = serde_json::to_vec(&mcp_config_json(&config.mcp_servers))?;
259    std::fs::write(&path, body).context("write temporary Claude MCP config")?;
260    Ok(Some(path))
261}
262
263pub fn mcp_config_json(servers: &[McpServer]) -> Value {
264    let mut mcp_servers = serde_json::Map::new();
265    for server in servers {
266        match server {
267            McpServer::Stdio(server) => {
268                let env = server
269                    .env
270                    .iter()
271                    .map(|var| (var.name.clone(), Value::String(var.value.clone())))
272                    .collect::<serde_json::Map<_, _>>();
273                mcp_servers.insert(
274                    server.name.clone(),
275                    json!({
276                        "type": "stdio",
277                        "command": server.command,
278                        "args": server.args,
279                        "env": env,
280                    }),
281                );
282            }
283            McpServer::Http(server) => {
284                mcp_servers.insert(
285                    server.name.clone(),
286                    json!({
287                        "type": "http",
288                        "url": server.url,
289                        "headers": headers_json(&server.headers),
290                    }),
291                );
292            }
293            McpServer::Sse(server) => {
294                mcp_servers.insert(
295                    server.name.clone(),
296                    json!({
297                        "type": "sse",
298                        "url": server.url,
299                        "headers": headers_json(&server.headers),
300                    }),
301                );
302            }
303            _ => {}
304        }
305    }
306    json!({ "mcpServers": mcp_servers })
307}
308
309fn headers_json(
310    headers: &[agent_client_protocol::schema::HttpHeader],
311) -> serde_json::Map<String, Value> {
312    headers
313        .iter()
314        .map(|header| (header.name.clone(), Value::String(header.value.clone())))
315        .collect()
316}
317
318impl Drop for ClaudePtySession {
319    fn drop(&mut self) {
320        drop(self.terminate());
321        drop(self.reader_thread.take());
322    }
323}
324
325fn reject_print_mode_args<'a>(args: impl IntoIterator<Item = &'a OsString>) -> anyhow::Result<()> {
326    for arg in args {
327        if arg == "-p" || arg == "--print" {
328            return Err(anyhow!(
329                "Claude PTY sessions must use interactive mode and cannot launch with {}",
330                arg.to_string_lossy()
331            ));
332        }
333        if arg.to_string_lossy().starts_with("--print=") {
334            return Err(anyhow!(
335                "Claude PTY sessions must use interactive mode and cannot launch with {}",
336                arg.to_string_lossy()
337            ));
338        }
339    }
340    Ok(())
341}