1use anyhow::{anyhow, Context, Result};
11use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
12use std::io::{Read, Write};
13use std::path::Path;
14use std::sync::mpsc::{self, TryRecvError};
15use std::sync::Arc;
16use std::thread;
17use std::time::{Duration, Instant};
18use tokio::sync::Mutex;
19use tracing::{debug, info, warn};
20
21pub const DEFAULT_COLS: u16 = 200;
23pub const DEFAULT_ROWS: u16 = 50;
24
25const MAX_OUTPUT_SIZE: usize = 1_000_000;
27
28const WCGW_PROMPT_PATTERN: &str = "◉";
30const WCGW_PROMPT_END: &str = "──➤";
31
32pub struct PtyShell {
37 master: Box<dyn MasterPty + Send>,
39 child: Box<dyn Child + Send + Sync>,
41 writer: Box<dyn Write + Send>,
43 output_rx: mpsc::Receiver<String>,
45 size: PtySize,
47 pub last_command: String,
49 pub output_buffer: String,
51 pub command_running: bool,
53 max_output_size: usize,
55 pub output_truncated: bool,
57}
58
59impl std::fmt::Debug for PtyShell {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.debug_struct("PtyShell")
62 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
63 .field("last_command", &self.last_command)
64 .field("command_running", &self.command_running)
65 .field("output_truncated", &self.output_truncated)
66 .field("output_buffer_len", &self.output_buffer.len())
67 .finish_non_exhaustive()
68 }
69}
70
71impl PtyShell {
72 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
81 info!(
82 "Creating new PTY shell (restricted: {}) in {}",
83 restricted_mode,
84 initial_dir.display()
85 );
86
87 let pty_system = native_pty_system();
89
90 let size =
92 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
93
94 let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
96
97 let mut cmd = CommandBuilder::new("bash");
99 if restricted_mode {
100 cmd.arg("-r");
101 }
102
103 cmd.env("TERM", "xterm-256color");
105 cmd.env("COLORTERM", "truecolor");
106 cmd.env("PAGER", "cat");
107 cmd.env("GIT_PAGER", "cat");
108 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
109 cmd.env("ROWS", DEFAULT_ROWS.to_string());
110 cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
113 cmd.cwd(initial_dir);
114
115 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
117
118 let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
120 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
121
122 let (output_tx, output_rx) = mpsc::channel::<String>();
124
125 thread::spawn(move || {
128 let mut buf = [0u8; 4096];
129 loop {
130 match reader.read(&mut buf) {
131 Ok(0) => {
132 break;
134 }
135 Ok(n) => {
136 let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
137 if output_tx.send(chunk).is_err() {
138 break;
140 }
141 }
142 Err(e) => {
143 debug!("PTY reader thread error: {}", e);
144 break;
145 }
146 }
147 }
148 debug!("PTY reader thread exiting");
149 });
150
151 let mut shell = Self {
153 master: pair.master,
154 child,
155 writer,
156 output_rx,
157 size,
158 last_command: String::new(),
159 output_buffer: String::new(),
160 command_running: false,
161 max_output_size: MAX_OUTPUT_SIZE,
162 output_truncated: false,
163 };
164
165 shell.initialize_prompt()?;
167
168 debug!("PTY shell created successfully");
169 Ok(shell)
170 }
171
172 fn initialize_prompt(&mut self) -> Result<()> {
174 let prompt_statement =
177 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤ " "$PWD"'"#;
178
179 self.write_command(prompt_statement)?;
180
181 std::thread::sleep(Duration::from_millis(100));
183 let _ = self.drain_output();
184
185 Ok(())
186 }
187
188 fn write_command(&mut self, command: &str) -> Result<()> {
190 let cmd_with_newline = format!("{command}\n");
192 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
193 self.writer.flush().context("Failed to flush PTY")?;
194 Ok(())
195 }
196
197 fn drain_output(&mut self) -> String {
199 let mut output = String::new();
200 let deadline = Instant::now() + Duration::from_millis(200);
201
202 while Instant::now() < deadline {
204 match self.output_rx.try_recv() {
205 Ok(chunk) => {
206 output.push_str(&chunk);
207
208 if output.len() > self.max_output_size {
210 self.output_truncated = true;
211 break;
212 }
213 }
214 Err(TryRecvError::Empty) => {
215 thread::sleep(Duration::from_millis(10));
217 }
218 Err(TryRecvError::Disconnected) => {
219 break;
221 }
222 }
223 }
224
225 output
226 }
227
228 pub fn send_command(&mut self, command: &str) -> Result<()> {
230 debug!("PTY sending command: {}", command);
231
232 self.output_buffer.clear();
234 self.output_truncated = false;
235 self.last_command = command.to_string();
236 self.command_running = true;
237
238 self.write_command(command)?;
240
241 Ok(())
242 }
243
244 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
249 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
250 let start = Instant::now();
251 let mut complete = false;
252 let mut no_data_count = 0;
253 let mut prompt_detected_at: Option<Instant> = None;
254
255 while start.elapsed() < timeout {
256 match self.output_rx.try_recv() {
257 Ok(chunk) => {
258 self.output_buffer.push_str(&chunk);
259 no_data_count = 0;
260
261 if prompt_detected_at.is_none()
263 && (Self::check_prompt_complete(&chunk)
264 || Self::check_prompt_complete(&self.output_buffer))
265 {
266 prompt_detected_at = Some(Instant::now());
267 debug!("Prompt detected, draining remaining output...");
268 }
269
270 if self.output_buffer.len() > self.max_output_size {
272 self.output_truncated = true;
273 let truncate_msg = "\n(...output truncated...)\n";
274 let keep_size = self.max_output_size / 2;
275 self.output_buffer = format!(
276 "{}{}",
277 truncate_msg,
278 &self.output_buffer[self.output_buffer.len() - keep_size..]
279 );
280 }
281 }
282 Err(TryRecvError::Empty) => {
283 thread::sleep(Duration::from_millis(10));
285 no_data_count += 1;
286
287 if let Some(detected_time) = prompt_detected_at {
289 if detected_time.elapsed() > Duration::from_millis(100) {
291 complete = true;
292 debug!("Command completed - prompt detected and drained");
293 break;
294 }
295 } else if no_data_count > 10 && Self::check_prompt_complete(&self.output_buffer)
296 {
297 prompt_detected_at = Some(Instant::now());
299 debug!("Prompt detected after wait, draining...");
300 }
301 }
302 Err(TryRecvError::Disconnected) => {
303 warn!("PTY reader disconnected");
305 complete = true;
306 break;
307 }
308 }
309 }
310
311 if complete || prompt_detected_at.is_some() {
312 self.command_running = false;
313 complete = true;
314 }
315
316 Ok((self.output_buffer.clone(), complete))
317 }
318
319 fn check_prompt_complete(text: &str) -> bool {
321 text.contains(WCGW_PROMPT_PATTERN) && text.contains(WCGW_PROMPT_END)
323 }
324
325 pub fn send_interrupt(&mut self) -> Result<()> {
327 debug!("PTY sending Ctrl+C");
328 self.writer
329 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
331 self.writer.flush()?;
332 Ok(())
333 }
334
335 pub fn send_eof(&mut self) -> Result<()> {
337 debug!("PTY sending Ctrl+D");
338 self.writer
339 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
341 self.writer.flush()?;
342 Ok(())
343 }
344
345 pub fn send_suspend(&mut self) -> Result<()> {
347 debug!("PTY sending Ctrl+Z");
348 self.writer
349 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
351 self.writer.flush()?;
352 Ok(())
353 }
354
355 pub fn send_text(&mut self, text: &str) -> Result<()> {
357 debug!("PTY sending text: {:?}", text);
358 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
359 Ok(())
360 }
361
362 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
364 self.writer.write_all(bytes).context("Failed to send bytes")?;
365 self.writer.flush()?;
366 Ok(())
367 }
368
369 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
371 let bytes: &[u8] = match key {
372 "Enter" => b"\r",
373 "Tab" => b"\t",
374 "Backspace" => b"\x7F",
375 "Escape" => b"\x1B",
376 "Up" | "KeyUp" => b"\x1B[A",
377 "Down" | "KeyDown" => b"\x1B[B",
378 "Right" | "KeyRight" => b"\x1B[C",
379 "Left" | "KeyLeft" => b"\x1B[D",
380 "Home" => b"\x1B[H",
381 "End" => b"\x1B[F",
382 "PageUp" => b"\x1B[5~",
383 "PageDown" => b"\x1B[6~",
384 "Delete" => b"\x1B[3~",
385 "Insert" => b"\x1B[2~",
386 "CtrlC" | "Ctrl-C" => b"\x03",
387 "CtrlD" | "Ctrl-D" => b"\x04",
388 "CtrlZ" | "Ctrl-Z" => b"\x1A",
389 "CtrlL" | "Ctrl-L" => b"\x0C",
390 _ => return Err(anyhow!("Unknown special key: {key}")),
391 };
392
393 debug!("PTY sending special key: {} ({:?})", key, bytes);
394 self.send_bytes(bytes)?;
395 Ok(())
396 }
397
398 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
400 debug!("PTY resizing to {}x{}", cols, rows);
401
402 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
403
404 self.master.resize(new_size).context("Failed to resize PTY")?;
405
406 self.size = new_size;
407 Ok(())
408 }
409
410 pub fn get_size(&self) -> (u16, u16) {
412 (self.size.cols, self.size.rows)
413 }
414
415 pub fn is_alive(&mut self) -> bool {
417 self.child.try_wait().is_ok_and(|status| status.is_none())
418 }
419}
420
421pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
423
424pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
426 let shell = PtyShell::new(initial_dir, restricted_mode)?;
427 Ok(Arc::new(Mutex::new(Some(shell))))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use tempfile::TempDir;
434
435 #[test]
436 fn test_pty_shell_creation() -> Result<()> {
437 let temp_dir = TempDir::new()?;
438 let result = PtyShell::new(temp_dir.path(), false);
439 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
440 Ok(())
441 }
442
443 #[test]
444 fn test_pty_shell_echo() -> Result<()> {
445 let temp_dir = TempDir::new()?;
446 let mut shell = PtyShell::new(temp_dir.path(), false)?;
447
448 shell.send_command("echo 'hello pty'")?;
449 let (output, _complete) = shell.read_output(2.0)?;
450
451 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
452 Ok(())
453 }
454
455 #[test]
456 fn test_pty_shell_pwd() -> Result<()> {
457 let temp_dir = TempDir::new()?;
458 let mut shell = PtyShell::new(temp_dir.path(), false)?;
459
460 shell.send_command("pwd && echo 'pwd_done'")?;
463 let (output, _complete) = shell.read_output(2.0)?;
464
465 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
467 Ok(())
468 }
469
470 #[test]
471 fn test_pty_resize() -> Result<()> {
472 let temp_dir = TempDir::new()?;
473 let mut shell = PtyShell::new(temp_dir.path(), false)?;
474
475 let result = shell.resize(120, 40);
476 assert!(result.is_ok());
477
478 let (cols, rows) = shell.get_size();
479 assert_eq!(cols, 120);
480 assert_eq!(rows, 40);
481 Ok(())
482 }
483}