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}