Skip to main content

winx_code_agent/state/
pty.rs

1//! Real PTY implementation using portable-pty
2//!
3//! This module provides a true pseudo-terminal interface for interactive
4//! shell sessions, enabling proper handling of:
5//! - ANSI escape sequences and colors
6//! - Interactive programs (sudo, vim, less, etc.)
7//! - Terminal resize events
8//! - Job control signals (Ctrl+C, Ctrl+Z, etc.)
9
10use 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
21/// Default terminal dimensions (columns x rows)
22pub const DEFAULT_COLS: u16 = 200;
23pub const DEFAULT_ROWS: u16 = 50;
24
25/// Maximum output buffer size to prevent memory issues
26const MAX_OUTPUT_SIZE: usize = 1_000_000;
27
28/// WCGW-style prompt pattern for command completion detection
29const WCGW_PROMPT_PATTERN: &str = "◉";
30const WCGW_PROMPT_END: &str = "──➤";
31
32/// Real PTY-based interactive shell
33///
34/// Uses portable-pty for true pseudo-terminal functionality,
35/// enabling proper handling of interactive programs like sudo, vim, etc.
36pub struct PtyShell {
37    /// The PTY master handle for resize operations
38    master: Box<dyn MasterPty + Send>,
39    /// Writer for PTY input (taken from master)
40    writer: Box<dyn Write + Send>,
41    /// Channel receiver for output from reader thread
42    output_rx: mpsc::Receiver<String>,
43    /// Current terminal size
44    size: PtySize,
45    /// Last command executed
46    pub last_command: String,
47    /// Accumulated output buffer
48    pub output_buffer: String,
49    /// Whether a command is currently running
50    pub command_running: bool,
51    /// Maximum output size before truncation
52    max_output_size: usize,
53    /// Flag for output truncation
54    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    /// Create a new PTY shell session
71    ///
72    /// # Arguments
73    /// * `initial_dir` - Starting directory for the shell
74    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
75    ///
76    /// # Returns
77    /// A new `PtyShell` instance with an active bash session
78    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        // Initialize the native PTY system
86        let pty_system = native_pty_system();
87
88        // Configure terminal size
89        let size =
90            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
91
92        // Open the PTY pair (master + slave)
93        let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
94
95        // Build the command
96        let mut cmd = CommandBuilder::new("bash");
97        if restricted_mode {
98            cmd.arg("-r");
99        }
100
101        // Set up environment for proper terminal behavior
102        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        // WCGW-style prompt for command completion detection
109        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
110        cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
111        cmd.cwd(initial_dir);
112
113        // Spawn bash in the PTY slave
114        let _child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
115
116        // Get reader and writer from master
117        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        // Create channel for output from reader thread
121        let (output_tx, output_rx) = mpsc::channel::<String>();
122
123        // Spawn a background thread to read from the PTY
124        // This prevents blocking the main thread
125        thread::spawn(move || {
126            let mut buf = [0u8; 4096];
127            loop {
128                match reader.read(&mut buf) {
129                    Ok(0) => {
130                        // EOF - PTY closed
131                        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                            // Receiver dropped, exit thread
137                            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        // Create the shell instance
150        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        // Initialize the shell with WCGW-style prompt
163        shell.initialize_prompt()?;
164
165        debug!("PTY shell created successfully");
166        Ok(shell)
167    }
168
169    /// Initialize the shell prompt for WCGW compatibility
170    fn initialize_prompt(&mut self) -> Result<()> {
171        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT
172        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
173        let prompt_statement =
174            r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤ " "$PWD"'"#;
175
176        self.write_command(prompt_statement)?;
177
178        // Wait for prompt to be ready
179        std::thread::sleep(Duration::from_millis(100));
180        let _ = self.drain_output();
181
182        Ok(())
183    }
184
185    /// Write a command to the PTY
186    fn write_command(&mut self, command: &str) -> Result<()> {
187        // Commands in PTY need \r\n for proper terminal behavior
188        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    /// Drain any pending output from the PTY channel
195    fn drain_output(&mut self) -> String {
196        let mut output = String::new();
197        let deadline = Instant::now() + Duration::from_millis(200);
198
199        // Drain all available output from the channel
200        while Instant::now() < deadline {
201            match self.output_rx.try_recv() {
202                Ok(chunk) => {
203                    output.push_str(&chunk);
204
205                    // Prevent runaway reads
206                    if output.len() > self.max_output_size {
207                        self.output_truncated = true;
208                        break;
209                    }
210                }
211                Err(TryRecvError::Empty) => {
212                    // No more data, wait briefly for more
213                    thread::sleep(Duration::from_millis(10));
214                }
215                Err(TryRecvError::Disconnected) => {
216                    // Reader thread died
217                    break;
218                }
219            }
220        }
221
222        output
223    }
224
225    /// Send a command to the shell and start reading output
226    pub fn send_command(&mut self, command: &str) -> Result<()> {
227        debug!("PTY sending command: {}", command);
228
229        // Clear previous state
230        self.output_buffer.clear();
231        self.output_truncated = false;
232        self.last_command = command.to_string();
233        self.command_running = true;
234
235        // Write the command
236        self.write_command(command)?;
237
238        Ok(())
239    }
240
241    /// Read output from the PTY with timeout
242    ///
243    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
244    /// whether the command has finished (prompt detected)
245    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                    // Check for WCGW prompt indicating command completion
259                    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                    // Truncate if too large
268                    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                    // No data available, wait briefly
281                    thread::sleep(Duration::from_millis(10));
282                    no_data_count += 1;
283
284                    // If prompt was detected, check if we've drained long enough
285                    if let Some(detected_time) = prompt_detected_at {
286                        // Wait 100ms after prompt detection to capture any trailing output
287                        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 during empty reads
295                        prompt_detected_at = Some(Instant::now());
296                        debug!("Prompt detected after wait, draining...");
297                    }
298                }
299                Err(TryRecvError::Disconnected) => {
300                    // Reader thread died - PTY closed
301                    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    /// Check if the output contains the WCGW-style prompt
317    fn check_prompt_complete(text: &str) -> bool {
318        // Look for the WCGW prompt pattern: ◉ /path──➤
319        text.contains(WCGW_PROMPT_PATTERN) && text.contains(WCGW_PROMPT_END)
320    }
321
322    /// Send Ctrl+C (interrupt) to the PTY
323    pub fn send_interrupt(&mut self) -> Result<()> {
324        debug!("PTY sending Ctrl+C");
325        self.writer
326            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
327            .context("Failed to send Ctrl+C")?;
328        self.writer.flush()?;
329        Ok(())
330    }
331
332    /// Send Ctrl+D (EOF) to the PTY
333    pub fn send_eof(&mut self) -> Result<()> {
334        debug!("PTY sending Ctrl+D");
335        self.writer
336            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
337            .context("Failed to send Ctrl+D")?;
338        self.writer.flush()?;
339        Ok(())
340    }
341
342    /// Send Ctrl+Z (suspend) to the PTY
343    pub fn send_suspend(&mut self) -> Result<()> {
344        debug!("PTY sending Ctrl+Z");
345        self.writer
346            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
347            .context("Failed to send Ctrl+Z")?;
348        self.writer.flush()?;
349        Ok(())
350    }
351
352    /// Send text directly to the PTY (for interactive input)
353    pub fn send_text(&mut self, text: &str) -> Result<()> {
354        debug!("PTY sending text: {:?}", text);
355        self.send_bytes(text.as_bytes()).context("Failed to send text")?;
356        Ok(())
357    }
358
359    /// Send raw bytes directly to the PTY.
360    pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
361        self.writer.write_all(bytes).context("Failed to send bytes")?;
362        self.writer.flush()?;
363        Ok(())
364    }
365
366    /// Send a special key sequence
367    pub fn send_special_key(&mut self, key: &str) -> Result<()> {
368        let bytes: &[u8] = match key {
369            "Enter" => b"\r",
370            "Tab" => b"\t",
371            "Backspace" => b"\x7F",
372            "Escape" => b"\x1B",
373            "Up" | "KeyUp" => b"\x1B[A",
374            "Down" | "KeyDown" => b"\x1B[B",
375            "Right" | "KeyRight" => b"\x1B[C",
376            "Left" | "KeyLeft" => b"\x1B[D",
377            "Home" => b"\x1B[H",
378            "End" => b"\x1B[F",
379            "PageUp" => b"\x1B[5~",
380            "PageDown" => b"\x1B[6~",
381            "Delete" => b"\x1B[3~",
382            "Insert" => b"\x1B[2~",
383            "CtrlC" | "Ctrl-C" => b"\x03",
384            "CtrlD" | "Ctrl-D" => b"\x04",
385            "CtrlZ" | "Ctrl-Z" => b"\x1A",
386            "CtrlL" | "Ctrl-L" => b"\x0C",
387            _ => return Err(anyhow!("Unknown special key: {key}")),
388        };
389
390        debug!("PTY sending special key: {} ({:?})", key, bytes);
391        self.send_bytes(bytes)?;
392        Ok(())
393    }
394
395    /// Resize the terminal
396    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
397        debug!("PTY resizing to {}x{}", cols, rows);
398
399        let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
400
401        self.master.resize(new_size).context("Failed to resize PTY")?;
402
403        self.size = new_size;
404        Ok(())
405    }
406
407    /// Get current terminal size
408    pub fn get_size(&self) -> (u16, u16) {
409        (self.size.cols, self.size.rows)
410    }
411
412    /// Check if the shell is still alive
413    pub fn is_alive(&self) -> bool {
414        // Try a non-blocking read to check if PTY is still valid
415        // If we get an error that's not WouldBlock, the PTY is dead
416        true // PTY handles this internally
417    }
418}
419
420/// Thread-safe wrapper for `PtyShell`
421pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
422
423/// Create a new shared PTY shell
424pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
425    let shell = PtyShell::new(initial_dir, restricted_mode)?;
426    Ok(Arc::new(Mutex::new(Some(shell))))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use tempfile::TempDir;
433
434    #[test]
435    fn test_pty_shell_creation() -> Result<()> {
436        let temp_dir = TempDir::new()?;
437        let result = PtyShell::new(temp_dir.path(), false);
438        assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
439        Ok(())
440    }
441
442    #[test]
443    fn test_pty_shell_echo() -> Result<()> {
444        let temp_dir = TempDir::new()?;
445        let mut shell = PtyShell::new(temp_dir.path(), false)?;
446
447        shell.send_command("echo 'hello pty'")?;
448        let (output, _complete) = shell.read_output(2.0)?;
449
450        assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
451        Ok(())
452    }
453
454    #[test]
455    fn test_pty_shell_pwd() -> Result<()> {
456        let temp_dir = TempDir::new()?;
457        let mut shell = PtyShell::new(temp_dir.path(), false)?;
458
459        // Simply verify shell responds to pwd command
460        // Use single quotes like echo test for consistency
461        shell.send_command("pwd && echo 'pwd_done'")?;
462        let (output, _complete) = shell.read_output(2.0)?;
463
464        // Verify the echo marker appears (proves command executed)
465        assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
466        Ok(())
467    }
468
469    #[test]
470    fn test_pty_resize() -> Result<()> {
471        let temp_dir = TempDir::new()?;
472        let mut shell = PtyShell::new(temp_dir.path(), false)?;
473
474        let result = shell.resize(120, 40);
475        assert!(result.is_ok());
476
477        let (cols, rows) = shell.get_size();
478        assert_eq!(cols, 120);
479        assert_eq!(rows, 40);
480        Ok(())
481    }
482}