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, 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
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    /// Child process running the shell
40    child: Box<dyn Child + Send + Sync>,
41    /// Writer for PTY input (taken from master)
42    writer: Box<dyn Write + Send>,
43    /// Channel receiver for output from reader thread
44    output_rx: mpsc::Receiver<String>,
45    /// Current terminal size
46    size: PtySize,
47    /// Last command executed
48    pub last_command: String,
49    /// Accumulated output buffer
50    pub output_buffer: String,
51    /// Whether a command is currently running
52    pub command_running: bool,
53    /// Maximum output size before truncation
54    max_output_size: usize,
55    /// Flag for output truncation
56    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    /// Create a new PTY shell session
73    ///
74    /// # Arguments
75    /// * `initial_dir` - Starting directory for the shell
76    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
77    ///
78    /// # Returns
79    /// A new `PtyShell` instance with an active bash session
80    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        // Initialize the native PTY system
88        let pty_system = native_pty_system();
89
90        // Configure terminal size
91        let size =
92            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
93
94        // Open the PTY pair (master + slave)
95        let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
96
97        // Build the command
98        let mut cmd = CommandBuilder::new("bash");
99        if restricted_mode {
100            cmd.arg("-r");
101        }
102
103        // Set up environment for proper terminal behavior
104        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        // WCGW-style prompt for command completion detection
111        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
112        cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
113        cmd.cwd(initial_dir);
114
115        // Spawn bash in the PTY slave
116        let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
117
118        // Get reader and writer from master
119        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        // Create channel for output from reader thread
123        let (output_tx, output_rx) = mpsc::channel::<String>();
124
125        // Spawn a background thread to read from the PTY
126        // This prevents blocking the main thread
127        thread::spawn(move || {
128            let mut buf = [0u8; 4096];
129            loop {
130                match reader.read(&mut buf) {
131                    Ok(0) => {
132                        // EOF - PTY closed
133                        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                            // Receiver dropped, exit thread
139                            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        // Create the shell instance
152        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        // Initialize the shell with WCGW-style prompt
166        shell.initialize_prompt()?;
167
168        debug!("PTY shell created successfully");
169        Ok(shell)
170    }
171
172    /// Initialize the shell prompt for WCGW compatibility
173    fn initialize_prompt(&mut self) -> Result<()> {
174        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT
175        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
176        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        // Wait for prompt to be ready
182        std::thread::sleep(Duration::from_millis(100));
183        let _ = self.drain_output();
184
185        Ok(())
186    }
187
188    /// Write a command to the PTY
189    fn write_command(&mut self, command: &str) -> Result<()> {
190        // Commands in PTY need \r\n for proper terminal behavior
191        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    /// Drain any pending output from the PTY channel
198    fn drain_output(&mut self) -> String {
199        let mut output = String::new();
200        let deadline = Instant::now() + Duration::from_millis(200);
201
202        // Drain all available output from the channel
203        while Instant::now() < deadline {
204            match self.output_rx.try_recv() {
205                Ok(chunk) => {
206                    output.push_str(&chunk);
207
208                    // Prevent runaway reads
209                    if output.len() > self.max_output_size {
210                        self.output_truncated = true;
211                        break;
212                    }
213                }
214                Err(TryRecvError::Empty) => {
215                    // No more data, wait briefly for more
216                    thread::sleep(Duration::from_millis(10));
217                }
218                Err(TryRecvError::Disconnected) => {
219                    // Reader thread died
220                    break;
221                }
222            }
223        }
224
225        output
226    }
227
228    /// Send a command to the shell and start reading output
229    pub fn send_command(&mut self, command: &str) -> Result<()> {
230        debug!("PTY sending command: {}", command);
231
232        // Clear previous state
233        self.output_buffer.clear();
234        self.output_truncated = false;
235        self.last_command = command.to_string();
236        self.command_running = true;
237
238        // Write the command
239        self.write_command(command)?;
240
241        Ok(())
242    }
243
244    /// Read output from the PTY with timeout
245    ///
246    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
247    /// whether the command has finished (prompt detected)
248    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                    // Check for WCGW prompt indicating command completion
262                    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                    // Truncate if too large
271                    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                    // No data available, wait briefly
284                    thread::sleep(Duration::from_millis(10));
285                    no_data_count += 1;
286
287                    // If prompt was detected, check if we've drained long enough
288                    if let Some(detected_time) = prompt_detected_at {
289                        // Wait 100ms after prompt detection to capture any trailing output
290                        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 during empty reads
298                        prompt_detected_at = Some(Instant::now());
299                        debug!("Prompt detected after wait, draining...");
300                    }
301                }
302                Err(TryRecvError::Disconnected) => {
303                    // Reader thread died - PTY closed
304                    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    /// Check if the output contains the WCGW-style prompt
320    fn check_prompt_complete(text: &str) -> bool {
321        // Look for the WCGW prompt pattern: ◉ /path──➤
322        text.contains(WCGW_PROMPT_PATTERN) && text.contains(WCGW_PROMPT_END)
323    }
324
325    /// Send Ctrl+C (interrupt) to the PTY
326    pub fn send_interrupt(&mut self) -> Result<()> {
327        debug!("PTY sending Ctrl+C");
328        self.writer
329            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
330            .context("Failed to send Ctrl+C")?;
331        self.writer.flush()?;
332        Ok(())
333    }
334
335    /// Send Ctrl+D (EOF) to the PTY
336    pub fn send_eof(&mut self) -> Result<()> {
337        debug!("PTY sending Ctrl+D");
338        self.writer
339            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
340            .context("Failed to send Ctrl+D")?;
341        self.writer.flush()?;
342        Ok(())
343    }
344
345    /// Send Ctrl+Z (suspend) to the PTY
346    pub fn send_suspend(&mut self) -> Result<()> {
347        debug!("PTY sending Ctrl+Z");
348        self.writer
349            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
350            .context("Failed to send Ctrl+Z")?;
351        self.writer.flush()?;
352        Ok(())
353    }
354
355    /// Send text directly to the PTY (for interactive input)
356    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    /// Send raw bytes directly to the PTY.
363    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    /// Send a special key sequence
370    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    /// Resize the terminal
399    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    /// Get current terminal size
411    pub fn get_size(&self) -> (u16, u16) {
412        (self.size.cols, self.size.rows)
413    }
414
415    /// Check if the shell is still alive
416    pub fn is_alive(&mut self) -> bool {
417        self.child.try_wait().is_ok_and(|status| status.is_none())
418    }
419}
420
421/// Thread-safe wrapper for `PtyShell`
422pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
423
424/// Create a new shared PTY shell
425pub 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        // Simply verify shell responds to pwd command
461        // Use single quotes like echo test for consistency
462        shell.send_command("pwd && echo 'pwd_done'")?;
463        let (output, _complete) = shell.read_output(2.0)?;
464
465        // Verify the echo marker appears (proves command executed)
466        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}