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::collections::hash_map::DefaultHasher;
13use std::collections::VecDeque;
14use std::hash::{Hash, Hasher};
15use std::io::{Read, Write};
16use std::path::Path;
17use std::process::Command;
18use std::sync::mpsc::{self, TryRecvError};
19use std::sync::Arc;
20use std::thread;
21use std::time::{Duration, Instant};
22use tokio::sync::Mutex;
23use tracing::{debug, info, warn};
24
25/// Default terminal dimensions (columns x rows)
26pub const DEFAULT_COLS: u16 = 200;
27pub const DEFAULT_ROWS: u16 = 50;
28
29/// Maximum output buffer size to prevent memory issues
30const MAX_OUTPUT_SIZE: usize = 1_000_000;
31
32/// How many fully-formed lines to keep in the per-shell ringbuffer. Callers can
33/// ask for at most this many lines of historical context via
34/// `StatusCheck.scrollback_lines`.
35pub const RING_BUFFER_LINES: usize = 2_000;
36
37/// WCGW-style prompt pattern for command completion detection
38const WCGW_PROMPT_PATTERN: &str = "◉";
39const WCGW_PROMPT_END: &str = "──➤";
40
41fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>) {
42    let requested = std::env::var("WINX_ATTACH_TERMINAL")
43        .or_else(|_| std::env::var("WINX_USE_SCREEN"))
44        .unwrap_or_default();
45    if !requested.is_empty() && requested != "0" && requested != "false" {
46        let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
47        if requested == "tmux" && command_available("tmux") {
48            let mut cmd = CommandBuilder::new("tmux");
49            cmd.args(["new-session", "-A", "-s", &session, "bash"]);
50            if restricted_mode {
51                cmd.arg("-r");
52            }
53            return (cmd, Some(format!("tmux attach -t {session}")));
54        }
55        if command_available("screen") {
56            // Parity with wcgw: ensure a sane ~/.screenrc and reap sessions whose
57            // creating winx process has died before spawning a fresh one.
58            ensure_screenrc();
59            cleanup_orphaned_screens();
60            let mut cmd = CommandBuilder::new("screen");
61            cmd.args(["-q", "-S", &session, "bash"]);
62            if restricted_mode {
63                cmd.arg("-r");
64            }
65            return (cmd, Some(format!("screen -x {session}")));
66        }
67    }
68
69    let mut cmd = CommandBuilder::new("bash");
70    if restricted_mode {
71        cmd.arg("-r");
72    }
73    (cmd, None)
74}
75
76fn command_available(command: &str) -> bool {
77    Command::new("sh")
78        .args(["-c", &format!("command -v {command}")])
79        .output()
80        .is_ok_and(|output| output.status.success())
81}
82
83/// Create `~/.screenrc` with a large scrollback if the user has none, matching
84/// wcgw's `check_if_screen_command_available`. Never overwrites an existing file.
85fn ensure_screenrc() {
86    let Some(home) = home::home_dir() else {
87        return;
88    };
89    let screenrc = home.join(".screenrc");
90    if screenrc.exists() {
91        return;
92    }
93    let _ = std::fs::write(
94        &screenrc,
95        "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
96    );
97}
98
99/// Reap detached `winx-*` screen sessions whose creating process is gone.
100///
101/// The session name embeds the creator PID (`winx-<pid>-<ts>`), so an orphan is
102/// simply a session whose `<pid>` no longer exists — the wcgw equivalent of
103/// detecting `parent_pid == 1`. Best-effort: any failure is silently ignored.
104fn cleanup_orphaned_screens() {
105    let Ok(output) = Command::new("screen").arg("-ls").output() else {
106        return;
107    };
108    // `screen -ls` exits non-zero when sessions exist, so we parse stdout regardless.
109    let listing = String::from_utf8_lossy(&output.stdout);
110    for line in listing.lines() {
111        let Some(session) = line.split_whitespace().next() else {
112            continue;
113        };
114        // session token looks like "<screen_pid>.winx-<creator_pid>-<ts>"
115        let Some((_, name)) = session.split_once('.') else {
116            continue;
117        };
118        if let Some(creator_pid) = winx_creator_pid(name) {
119            if !process_exists(creator_pid) {
120                let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
121            }
122        }
123    }
124}
125
126/// Extract the creator PID from a `winx-<pid>-<ts>` screen session name.
127fn winx_creator_pid(name: &str) -> Option<u32> {
128    name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
129}
130
131/// Whether a process with `pid` is currently alive (Linux `/proc` check).
132fn process_exists(pid: u32) -> bool {
133    std::path::Path::new("/proc").join(pid.to_string()).exists()
134}
135
136fn timestamp_millis() -> u128 {
137    std::time::SystemTime::now()
138        .duration_since(std::time::UNIX_EPOCH)
139        .map_or(0, |duration| duration.as_millis())
140}
141
142/// Real PTY-based interactive shell
143///
144/// Uses portable-pty for true pseudo-terminal functionality,
145/// enabling proper handling of interactive programs like sudo, vim, etc.
146pub struct PtyShell {
147    /// The PTY master handle for resize operations
148    master: Box<dyn MasterPty + Send>,
149    /// Child process running the shell
150    child: Box<dyn Child + Send + Sync>,
151    /// Writer for PTY input (taken from master)
152    writer: Box<dyn Write + Send>,
153    /// Channel receiver for output from reader thread
154    output_rx: mpsc::Receiver<String>,
155    /// Current terminal size
156    size: PtySize,
157    /// Last command executed
158    pub last_command: String,
159    /// Accumulated output buffer
160    pub output_buffer: String,
161    /// Whether a command is currently running
162    pub command_running: bool,
163    /// Maximum output size before truncation
164    max_output_size: usize,
165    /// Flag for output truncation
166    pub output_truncated: bool,
167    /// Rolling buffer of fully-emitted lines for opt-in scrollback. The newest
168    /// line is at the back; capped at `RING_BUFFER_LINES`.
169    pub line_ring: VecDeque<String>,
170    /// Carries the unterminated tail across reads so partial lines aren't
171    /// double-counted when more bytes arrive.
172    line_ring_partial: String,
173    /// Hash of the last rendered output we shipped to the caller. Used by the
174    /// delta path in `status_check` to elide repeats when the screen is idle.
175    pub last_returned_hash: Option<u64>,
176    /// Optional command a human can run to attach to the same terminal session.
177    pub attach_hint: Option<String>,
178}
179
180impl std::fmt::Debug for PtyShell {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("PtyShell")
183            .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
184            .field("last_command", &self.last_command)
185            .field("command_running", &self.command_running)
186            .field("output_truncated", &self.output_truncated)
187            .field("output_buffer_len", &self.output_buffer.len())
188            .field("attach_hint", &self.attach_hint)
189            .finish_non_exhaustive()
190    }
191}
192
193impl PtyShell {
194    /// Create a new PTY shell session
195    ///
196    /// # Arguments
197    /// * `initial_dir` - Starting directory for the shell
198    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
199    ///
200    /// # Returns
201    /// A new `PtyShell` instance with an active bash session
202    pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
203        info!(
204            "Creating new PTY shell (restricted: {}) in {}",
205            restricted_mode,
206            initial_dir.display()
207        );
208
209        // Initialize the native PTY system
210        let pty_system = native_pty_system();
211
212        // Configure terminal size
213        let size =
214            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
215
216        // Open the PTY pair (master + slave)
217        let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
218
219        // Build the command
220        let (mut cmd, attach_hint) = attachable_command(restricted_mode);
221
222        // Set up environment for proper terminal behavior
223        cmd.env("TERM", "xterm-256color");
224        cmd.env("COLORTERM", "truecolor");
225        cmd.env("PAGER", "cat");
226        cmd.env("GIT_PAGER", "cat");
227        cmd.env("COLUMNS", DEFAULT_COLS.to_string());
228        cmd.env("ROWS", DEFAULT_ROWS.to_string());
229        // WCGW-style prompt for command completion detection
230        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
231        cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
232        cmd.cwd(initial_dir);
233
234        // Spawn bash in the PTY slave
235        let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
236
237        // Get reader and writer from master
238        let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
239        let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
240
241        // Create channel for output from reader thread
242        let (output_tx, output_rx) = mpsc::channel::<String>();
243
244        // Spawn a background thread to read from the PTY
245        // This prevents blocking the main thread
246        thread::spawn(move || {
247            let mut buf = [0u8; 4096];
248            loop {
249                match reader.read(&mut buf) {
250                    Ok(0) => {
251                        // EOF - PTY closed
252                        break;
253                    }
254                    Ok(n) => {
255                        let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
256                        if output_tx.send(chunk).is_err() {
257                            // Receiver dropped, exit thread
258                            break;
259                        }
260                    }
261                    Err(e) => {
262                        debug!("PTY reader thread error: {}", e);
263                        break;
264                    }
265                }
266            }
267            debug!("PTY reader thread exiting");
268        });
269
270        // Create the shell instance
271        let mut shell = Self {
272            master: pair.master,
273            child,
274            writer,
275            output_rx,
276            size,
277            last_command: String::new(),
278            output_buffer: String::new(),
279            command_running: false,
280            max_output_size: MAX_OUTPUT_SIZE,
281            output_truncated: false,
282            line_ring: VecDeque::with_capacity(RING_BUFFER_LINES),
283            line_ring_partial: String::new(),
284            last_returned_hash: None,
285            attach_hint,
286        };
287
288        // Initialize the shell with WCGW-style prompt
289        shell.initialize_prompt()?;
290
291        debug!("PTY shell created successfully");
292        Ok(shell)
293    }
294
295    /// Initialize the shell prompt for WCGW compatibility
296    fn initialize_prompt(&mut self) -> Result<()> {
297        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT
298        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
299        let prompt_statement =
300            r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤ " "$PWD"'"#;
301
302        self.write_command(prompt_statement)?;
303
304        // Wait for prompt to be ready
305        std::thread::sleep(Duration::from_millis(100));
306        let _ = self.drain_output();
307
308        Ok(())
309    }
310
311    /// Write a command to the PTY
312    fn write_command(&mut self, command: &str) -> Result<()> {
313        // Commands in PTY need \r\n for proper terminal behavior
314        let cmd_with_newline = format!("{command}\n");
315        self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
316        self.writer.flush().context("Failed to flush PTY")?;
317        Ok(())
318    }
319
320    /// Drain any pending output from the PTY channel
321    fn drain_output(&mut self) -> String {
322        let mut output = String::new();
323        let deadline = Instant::now() + Duration::from_millis(200);
324
325        // Drain all available output from the channel
326        while Instant::now() < deadline {
327            match self.output_rx.try_recv() {
328                Ok(chunk) => {
329                    output.push_str(&chunk);
330
331                    // Prevent runaway reads
332                    if output.len() > self.max_output_size {
333                        self.output_truncated = true;
334                        break;
335                    }
336                }
337                Err(TryRecvError::Empty) => {
338                    // No more data, wait briefly for more
339                    thread::sleep(Duration::from_millis(10));
340                }
341                Err(TryRecvError::Disconnected) => {
342                    // Reader thread died
343                    break;
344                }
345            }
346        }
347
348        output
349    }
350
351    /// Drain any pending output and, if a previous command still seems to be
352    /// running, send a Ctrl-C to flush it. Mirrors wcgw's `clear_to_run` so a
353    /// new command never inherits stale prompt fragments or a half-typed line.
354    ///
355    /// Returns `true` if the shell looks idle (prompt seen), `false` if it
356    /// still wouldn't yield after the Ctrl-C — caller may want to reset.
357    pub fn clear_to_run(&mut self, max_wait_secs: f32) -> Result<bool> {
358        // Drain whatever is in the channel without blocking. Use the existing
359        // read_output to also catch the prompt fingerprint.
360        let (_, complete) = self.read_output(max_wait_secs.min(0.5))?;
361        if complete {
362            return Ok(true);
363        }
364
365        // Something is still running — interrupt it.
366        debug!("clear_to_run: prompt not seen, sending Ctrl+C");
367        self.send_interrupt()?;
368
369        // Re-drain after the interrupt so the next command starts on a clean prompt.
370        let (_, drained) = self.read_output(max_wait_secs)?;
371        Ok(drained)
372    }
373
374    /// Send a command to the shell and start reading output
375    pub fn send_command(&mut self, command: &str) -> Result<()> {
376        debug!("PTY sending command: {}", command);
377
378        // Clear previous state
379        self.output_buffer.clear();
380        self.output_truncated = false;
381        self.last_command = command.to_string();
382        self.command_running = true;
383        // A new command means the next status_check should return whatever
384        // shows up — drop the dedup hash so we don't elide the first response.
385        self.last_returned_hash = None;
386
387        // Write the command
388        self.write_command(command)?;
389
390        Ok(())
391    }
392
393    /// Push freshly-arrived bytes through the line-oriented ringbuffer so
394    /// callers can request bounded scrollback later.
395    fn ingest_into_ring(&mut self, chunk: &str) {
396        let combined = if self.line_ring_partial.is_empty() {
397            chunk.to_string()
398        } else {
399            let mut s = std::mem::take(&mut self.line_ring_partial);
400            s.push_str(chunk);
401            s
402        };
403
404        let mut last_nl_end: Option<usize> = None;
405        for (idx, ch) in combined.char_indices() {
406            if ch == '\n' {
407                let end = idx + ch.len_utf8();
408                let start = last_nl_end.unwrap_or(0);
409                let line = combined[start..idx].trim_end_matches('\r').to_string();
410                if self.line_ring.len() == RING_BUFFER_LINES {
411                    self.line_ring.pop_front();
412                }
413                self.line_ring.push_back(line);
414                last_nl_end = Some(end);
415            }
416        }
417
418        if let Some(end) = last_nl_end {
419            self.line_ring_partial = combined[end..].to_string();
420        } else {
421            self.line_ring_partial = combined;
422        }
423    }
424
425    /// Return up to `lines` recent lines from the ringbuffer, oldest first.
426    /// Includes any in-flight partial line.
427    pub fn collect_scrollback(&self, lines: usize) -> String {
428        if lines == 0 {
429            return String::new();
430        }
431        let start = self.line_ring.len().saturating_sub(lines);
432        let mut out = String::new();
433        for line in self.line_ring.iter().skip(start) {
434            out.push_str(line);
435            out.push('\n');
436        }
437        if !self.line_ring_partial.is_empty() {
438            out.push_str(&self.line_ring_partial);
439        }
440        out
441    }
442
443    /// Hash arbitrary rendered output into a u64 dedup key.
444    pub fn fingerprint(text: &str) -> u64 {
445        let mut hasher = DefaultHasher::new();
446        text.hash(&mut hasher);
447        hasher.finish()
448    }
449
450    /// Read output from the PTY with timeout
451    ///
452    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
453    /// whether the command has finished (prompt detected)
454    pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
455        let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
456        let start = Instant::now();
457        let mut complete = false;
458        let mut no_data_count = 0;
459        let mut prompt_detected_at: Option<Instant> = None;
460
461        while start.elapsed() < timeout {
462            match self.output_rx.try_recv() {
463                Ok(chunk) => {
464                    self.output_buffer.push_str(&chunk);
465                    self.ingest_into_ring(&chunk);
466                    no_data_count = 0;
467
468                    // Check for WCGW prompt indicating command completion
469                    if prompt_detected_at.is_none()
470                        && (Self::check_prompt_complete(&chunk)
471                            || Self::check_prompt_complete(&self.output_buffer))
472                    {
473                        prompt_detected_at = Some(Instant::now());
474                        debug!("Prompt detected, draining remaining output...");
475                    }
476
477                    // Truncate if too large
478                    if self.output_buffer.len() > self.max_output_size {
479                        self.output_truncated = true;
480                        let truncate_msg = "\n(...output truncated...)\n";
481                        let keep_size = self.max_output_size / 2;
482                        self.output_buffer = format!(
483                            "{}{}",
484                            truncate_msg,
485                            &self.output_buffer[self.output_buffer.len() - keep_size..]
486                        );
487                    }
488                }
489                Err(TryRecvError::Empty) => {
490                    // No data available, wait briefly
491                    thread::sleep(Duration::from_millis(10));
492                    no_data_count += 1;
493
494                    // If prompt was detected, check if we've drained long enough
495                    if let Some(detected_time) = prompt_detected_at {
496                        // Wait 100ms after prompt detection to capture any trailing output
497                        if detected_time.elapsed() > Duration::from_millis(100) {
498                            complete = true;
499                            debug!("Command completed - prompt detected and drained");
500                            break;
501                        }
502                    } else if no_data_count > 10 && Self::check_prompt_complete(&self.output_buffer)
503                    {
504                        // Prompt detected during empty reads
505                        prompt_detected_at = Some(Instant::now());
506                        debug!("Prompt detected after wait, draining...");
507                    }
508                }
509                Err(TryRecvError::Disconnected) => {
510                    // Reader thread died - PTY closed
511                    warn!("PTY reader disconnected");
512                    complete = true;
513                    break;
514                }
515            }
516        }
517
518        if complete || prompt_detected_at.is_some() {
519            self.command_running = false;
520            complete = true;
521        }
522
523        Ok((self.output_buffer.clone(), complete))
524    }
525
526    /// Check if the output contains the WCGW-style prompt
527    fn check_prompt_complete(text: &str) -> bool {
528        // Look for the WCGW prompt pattern: ◉ /path──➤
529        text.contains(WCGW_PROMPT_PATTERN) && text.contains(WCGW_PROMPT_END)
530    }
531
532    /// Send Ctrl+C (interrupt) to the PTY
533    pub fn send_interrupt(&mut self) -> Result<()> {
534        debug!("PTY sending Ctrl+C");
535        self.writer
536            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
537            .context("Failed to send Ctrl+C")?;
538        self.writer.flush()?;
539        Ok(())
540    }
541
542    /// Send Ctrl+D (EOF) to the PTY
543    pub fn send_eof(&mut self) -> Result<()> {
544        debug!("PTY sending Ctrl+D");
545        self.writer
546            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
547            .context("Failed to send Ctrl+D")?;
548        self.writer.flush()?;
549        Ok(())
550    }
551
552    /// Send Ctrl+Z (suspend) to the PTY
553    pub fn send_suspend(&mut self) -> Result<()> {
554        debug!("PTY sending Ctrl+Z");
555        self.writer
556            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
557            .context("Failed to send Ctrl+Z")?;
558        self.writer.flush()?;
559        Ok(())
560    }
561
562    /// Send text directly to the PTY (for interactive input)
563    pub fn send_text(&mut self, text: &str) -> Result<()> {
564        debug!("PTY sending text: {:?}", text);
565        self.send_bytes(text.as_bytes()).context("Failed to send text")?;
566        Ok(())
567    }
568
569    /// Send raw bytes directly to the PTY.
570    pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
571        self.writer.write_all(bytes).context("Failed to send bytes")?;
572        self.writer.flush()?;
573        Ok(())
574    }
575
576    /// Send a special key sequence
577    pub fn send_special_key(&mut self, key: &str) -> Result<()> {
578        let bytes: &[u8] = match key {
579            "Enter" => b"\r",
580            "Tab" => b"\t",
581            "Backspace" => b"\x7F",
582            "Escape" => b"\x1B",
583            "Up" | "KeyUp" => b"\x1B[A",
584            "Down" | "KeyDown" => b"\x1B[B",
585            "Right" | "KeyRight" => b"\x1B[C",
586            "Left" | "KeyLeft" => b"\x1B[D",
587            "Home" => b"\x1B[H",
588            "End" => b"\x1B[F",
589            "PageUp" => b"\x1B[5~",
590            "PageDown" => b"\x1B[6~",
591            "Delete" => b"\x1B[3~",
592            "Insert" => b"\x1B[2~",
593            "CtrlC" | "Ctrl-C" => b"\x03",
594            "CtrlD" | "Ctrl-D" => b"\x04",
595            "CtrlZ" | "Ctrl-Z" => b"\x1A",
596            "CtrlL" | "Ctrl-L" => b"\x0C",
597            _ => return Err(anyhow!("Unknown special key: {key}")),
598        };
599
600        debug!("PTY sending special key: {} ({:?})", key, bytes);
601        self.send_bytes(bytes)?;
602        Ok(())
603    }
604
605    /// Resize the terminal
606    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
607        debug!("PTY resizing to {}x{}", cols, rows);
608
609        let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
610
611        self.master.resize(new_size).context("Failed to resize PTY")?;
612
613        self.size = new_size;
614        Ok(())
615    }
616
617    /// Get current terminal size
618    pub fn get_size(&self) -> (u16, u16) {
619        (self.size.cols, self.size.rows)
620    }
621
622    /// Check if the shell is still alive
623    pub fn is_alive(&mut self) -> bool {
624        self.child.try_wait().is_ok_and(|status| status.is_none())
625    }
626}
627
628/// Thread-safe wrapper for `PtyShell`
629pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
630
631/// Create a new shared PTY shell
632pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
633    let shell = PtyShell::new(initial_dir, restricted_mode)?;
634    Ok(Arc::new(Mutex::new(Some(shell))))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use tempfile::TempDir;
641
642    #[test]
643    fn test_pty_shell_creation() -> Result<()> {
644        let temp_dir = TempDir::new()?;
645        let result = PtyShell::new(temp_dir.path(), false);
646        assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
647        Ok(())
648    }
649
650    #[test]
651    fn test_pty_shell_echo() -> Result<()> {
652        let temp_dir = TempDir::new()?;
653        let mut shell = PtyShell::new(temp_dir.path(), false)?;
654
655        shell.send_command("echo 'hello pty'")?;
656        let (output, _complete) = shell.read_output(2.0)?;
657
658        assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
659        Ok(())
660    }
661
662    #[test]
663    fn test_pty_shell_pwd() -> Result<()> {
664        let temp_dir = TempDir::new()?;
665        let mut shell = PtyShell::new(temp_dir.path(), false)?;
666
667        // Simply verify shell responds to pwd command
668        // Use single quotes like echo test for consistency
669        shell.send_command("pwd && echo 'pwd_done'")?;
670        let (output, _complete) = shell.read_output(2.0)?;
671
672        // Verify the echo marker appears (proves command executed)
673        assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
674        Ok(())
675    }
676
677    #[test]
678    fn test_pty_resize() -> Result<()> {
679        let temp_dir = TempDir::new()?;
680        let mut shell = PtyShell::new(temp_dir.path(), false)?;
681
682        let result = shell.resize(120, 40);
683        assert!(result.is_ok());
684
685        let (cols, rows) = shell.get_size();
686        assert_eq!(cols, 120);
687        assert_eq!(rows, 40);
688        Ok(())
689    }
690}