1use anyhow::{anyhow, Context, Result};
11use portable_pty::{native_pty_system, 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 writer: Box<dyn Write + Send>,
41 output_rx: mpsc::Receiver<String>,
43 size: PtySize,
45 pub last_command: String,
47 pub output_buffer: String,
49 pub command_running: bool,
51 max_output_size: usize,
53 pub output_truncated: bool,
55}
56
57impl std::fmt::Debug for PtyShell {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("PtyShell")
60 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
61 .field("last_command", &self.last_command)
62 .field("command_running", &self.command_running)
63 .field("output_truncated", &self.output_truncated)
64 .field("output_buffer_len", &self.output_buffer.len())
65 .finish_non_exhaustive()
66 }
67}
68
69impl PtyShell {
70 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
79 info!(
80 "Creating new PTY shell (restricted: {}) in {}",
81 restricted_mode,
82 initial_dir.display()
83 );
84
85 let pty_system = native_pty_system();
87
88 let size =
90 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
91
92 let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
94
95 let mut cmd = CommandBuilder::new("bash");
97 if restricted_mode {
98 cmd.arg("-r");
99 }
100
101 cmd.env("TERM", "xterm-256color");
103 cmd.env("COLORTERM", "truecolor");
104 cmd.env("PAGER", "cat");
105 cmd.env("GIT_PAGER", "cat");
106 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
107 cmd.env("ROWS", DEFAULT_ROWS.to_string());
108 cmd.env("PROMPT_COMMAND", r#"printf '◉ '"$(pwd)"'──➤ '"#);
111 cmd.cwd(initial_dir);
112
113 let _child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
115
116 let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
118 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
119
120 let (output_tx, output_rx) = mpsc::channel::<String>();
122
123 thread::spawn(move || {
126 let mut buf = [0u8; 4096];
127 loop {
128 match reader.read(&mut buf) {
129 Ok(0) => {
130 break;
132 }
133 Ok(n) => {
134 let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
135 if output_tx.send(chunk).is_err() {
136 break;
138 }
139 }
140 Err(e) => {
141 debug!("PTY reader thread error: {}", e);
142 break;
143 }
144 }
145 }
146 debug!("PTY reader thread exiting");
147 });
148
149 let mut shell = Self {
151 master: pair.master,
152 writer,
153 output_rx,
154 size,
155 last_command: String::new(),
156 output_buffer: String::new(),
157 command_running: false,
158 max_output_size: MAX_OUTPUT_SIZE,
159 output_truncated: false,
160 };
161
162 shell.initialize_prompt()?;
164
165 debug!("PTY shell created successfully");
166 Ok(shell)
167 }
168
169 fn initialize_prompt(&mut self) -> Result<()> {
171 let prompt_statement =
174 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ $(pwd)──➤ '"'"#;
175
176 self.write_command(prompt_statement)?;
177
178 std::thread::sleep(Duration::from_millis(100));
180 let _ = self.drain_output();
181
182 Ok(())
183 }
184
185 fn write_command(&mut self, command: &str) -> Result<()> {
187 let cmd_with_newline = format!("{command}\n");
189 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
190 self.writer.flush().context("Failed to flush PTY")?;
191 Ok(())
192 }
193
194 fn drain_output(&mut self) -> String {
196 let mut output = String::new();
197 let deadline = Instant::now() + Duration::from_millis(200);
198
199 while Instant::now() < deadline {
201 match self.output_rx.try_recv() {
202 Ok(chunk) => {
203 output.push_str(&chunk);
204
205 if output.len() > self.max_output_size {
207 self.output_truncated = true;
208 break;
209 }
210 }
211 Err(TryRecvError::Empty) => {
212 thread::sleep(Duration::from_millis(10));
214 }
215 Err(TryRecvError::Disconnected) => {
216 break;
218 }
219 }
220 }
221
222 output
223 }
224
225 pub fn send_command(&mut self, command: &str) -> Result<()> {
227 debug!("PTY sending command: {}", command);
228
229 self.output_buffer.clear();
231 self.output_truncated = false;
232 self.last_command = command.to_string();
233 self.command_running = true;
234
235 self.write_command(command)?;
237
238 Ok(())
239 }
240
241 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
246 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
247 let start = Instant::now();
248 let mut complete = false;
249 let mut no_data_count = 0;
250 let mut prompt_detected_at: Option<Instant> = None;
251
252 while start.elapsed() < timeout {
253 match self.output_rx.try_recv() {
254 Ok(chunk) => {
255 self.output_buffer.push_str(&chunk);
256 no_data_count = 0;
257
258 if prompt_detected_at.is_none()
260 && (Self::check_prompt_complete(&chunk)
261 || Self::check_prompt_complete(&self.output_buffer))
262 {
263 prompt_detected_at = Some(Instant::now());
264 debug!("Prompt detected, draining remaining output...");
265 }
266
267 if self.output_buffer.len() > self.max_output_size {
269 self.output_truncated = true;
270 let truncate_msg = "\n(...output truncated...)\n";
271 let keep_size = self.max_output_size / 2;
272 self.output_buffer = format!(
273 "{}{}",
274 truncate_msg,
275 &self.output_buffer[self.output_buffer.len() - keep_size..]
276 );
277 }
278 }
279 Err(TryRecvError::Empty) => {
280 thread::sleep(Duration::from_millis(10));
282 no_data_count += 1;
283
284 if let Some(detected_time) = prompt_detected_at {
286 if detected_time.elapsed() > Duration::from_millis(100) {
288 complete = true;
289 debug!("Command completed - prompt detected and drained");
290 break;
291 }
292 } else if no_data_count > 10 && Self::check_prompt_complete(&self.output_buffer)
293 {
294 prompt_detected_at = Some(Instant::now());
296 debug!("Prompt detected after wait, draining...");
297 }
298 }
299 Err(TryRecvError::Disconnected) => {
300 warn!("PTY reader disconnected");
302 complete = true;
303 break;
304 }
305 }
306 }
307
308 if complete || prompt_detected_at.is_some() {
309 self.command_running = false;
310 complete = true;
311 }
312
313 Ok((self.output_buffer.clone(), complete))
314 }
315
316 fn check_prompt_complete(text: &str) -> bool {
318 text.contains(WCGW_PROMPT_PATTERN) && text.contains(WCGW_PROMPT_END)
320 }
321
322 pub fn send_interrupt(&mut self) -> Result<()> {
324 debug!("PTY sending Ctrl+C");
325 self.writer
326 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
328 self.writer.flush()?;
329 Ok(())
330 }
331
332 pub fn send_eof(&mut self) -> Result<()> {
334 debug!("PTY sending Ctrl+D");
335 self.writer
336 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
338 self.writer.flush()?;
339 Ok(())
340 }
341
342 pub fn send_suspend(&mut self) -> Result<()> {
344 debug!("PTY sending Ctrl+Z");
345 self.writer
346 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
348 self.writer.flush()?;
349 Ok(())
350 }
351
352 pub fn send_text(&mut self, text: &str) -> Result<()> {
354 debug!("PTY sending text: {:?}", text);
355 self.writer.write_all(text.as_bytes()).context("Failed to send text")?;
356 self.writer.flush()?;
357 Ok(())
358 }
359
360 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
362 let bytes: &[u8] = match key {
363 "Enter" => b"\r",
364 "Tab" => b"\t",
365 "Backspace" => b"\x7F",
366 "Escape" => b"\x1B",
367 "Up" | "KeyUp" => b"\x1B[A",
368 "Down" | "KeyDown" => b"\x1B[B",
369 "Right" | "KeyRight" => b"\x1B[C",
370 "Left" | "KeyLeft" => b"\x1B[D",
371 "Home" => b"\x1B[H",
372 "End" => b"\x1B[F",
373 "PageUp" => b"\x1B[5~",
374 "PageDown" => b"\x1B[6~",
375 "Delete" => b"\x1B[3~",
376 "Insert" => b"\x1B[2~",
377 "CtrlC" | "Ctrl-C" => b"\x03",
378 "CtrlD" | "Ctrl-D" => b"\x04",
379 "CtrlZ" | "Ctrl-Z" => b"\x1A",
380 "CtrlL" | "Ctrl-L" => b"\x0C",
381 _ => return Err(anyhow!("Unknown special key: {key}")),
382 };
383
384 debug!("PTY sending special key: {} ({:?})", key, bytes);
385 self.writer.write_all(bytes)?;
386 self.writer.flush()?;
387 Ok(())
388 }
389
390 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
392 debug!("PTY resizing to {}x{}", cols, rows);
393
394 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
395
396 self.master.resize(new_size).context("Failed to resize PTY")?;
397
398 self.size = new_size;
399 Ok(())
400 }
401
402 pub fn get_size(&self) -> (u16, u16) {
404 (self.size.cols, self.size.rows)
405 }
406
407 pub fn is_alive(&self) -> bool {
409 true }
413}
414
415pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
417
418pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
420 let shell = PtyShell::new(initial_dir, restricted_mode)?;
421 Ok(Arc::new(Mutex::new(Some(shell))))
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use tempfile::TempDir;
428
429 #[test]
430 fn test_pty_shell_creation() -> Result<()> {
431 let temp_dir = TempDir::new()?;
432 let result = PtyShell::new(temp_dir.path(), false);
433 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
434 Ok(())
435 }
436
437 #[test]
438 fn test_pty_shell_echo() -> Result<()> {
439 let temp_dir = TempDir::new()?;
440 let mut shell = PtyShell::new(temp_dir.path(), false)?;
441
442 shell.send_command("echo 'hello pty'")?;
443 let (output, _complete) = shell.read_output(2.0)?;
444
445 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
446 Ok(())
447 }
448
449 #[test]
450 fn test_pty_shell_pwd() -> Result<()> {
451 let temp_dir = TempDir::new()?;
452 let mut shell = PtyShell::new(temp_dir.path(), false)?;
453
454 shell.send_command("pwd && echo 'pwd_done'")?;
457 let (output, _complete) = shell.read_output(2.0)?;
458
459 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
461 Ok(())
462 }
463
464 #[test]
465 fn test_pty_resize() -> Result<()> {
466 let temp_dir = TempDir::new()?;
467 let mut shell = PtyShell::new(temp_dir.path(), false)?;
468
469 let result = shell.resize(120, 40);
470 assert!(result.is_ok());
471
472 let (cols, rows) = shell.get_size();
473 assert_eq!(cols, 120);
474 assert_eq!(rows, 40);
475 Ok(())
476 }
477}