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>, bool) {
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}")), false);
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}")), false);
66        }
67    }
68
69    let shell = preferred_shell(restricted_mode);
70    let is_zsh = shell == "zsh";
71    let mut cmd = CommandBuilder::new(&shell);
72    // zsh's restricted mode isn't the `-r` flag, so restricted always uses bash -r.
73    if restricted_mode && !is_zsh {
74        cmd.arg("-r");
75    }
76    (cmd, None, is_zsh)
77}
78
79/// Shell to spawn directly. Defaults to bash; honors `WINX_SHELL=zsh` when zsh is
80/// on PATH and we're not in restricted mode (zsh's restricted mode differs from
81/// `bash -r`, so restricted falls back to bash).
82fn preferred_shell(restricted_mode: bool) -> String {
83    if !restricted_mode {
84        if let Ok(requested) = std::env::var("WINX_SHELL") {
85            if requested == "zsh" && command_available("zsh") {
86                return "zsh".to_string();
87            }
88        }
89    }
90    "bash".to_string()
91}
92
93fn command_available(command: &str) -> bool {
94    Command::new("sh")
95        .args(["-c", &format!("command -v {command}")])
96        .output()
97        .is_ok_and(|output| output.status.success())
98}
99
100/// Create `~/.screenrc` with a large scrollback if the user has none, matching
101/// wcgw's `check_if_screen_command_available`. Never overwrites an existing file.
102fn ensure_screenrc() {
103    let Some(home) = home::home_dir() else {
104        return;
105    };
106    let screenrc = home.join(".screenrc");
107    if screenrc.exists() {
108        return;
109    }
110    let _ = std::fs::write(
111        &screenrc,
112        "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
113    );
114}
115
116/// Reap detached `winx-*` screen sessions whose creating process is gone.
117///
118/// The session name embeds the creator PID (`winx-<pid>-<ts>`), so an orphan is
119/// simply a session whose `<pid>` no longer exists — the wcgw equivalent of
120/// detecting `parent_pid == 1`. Best-effort: any failure is silently ignored.
121fn cleanup_orphaned_screens() {
122    let Ok(output) = Command::new("screen").arg("-ls").output() else {
123        return;
124    };
125    // `screen -ls` exits non-zero when sessions exist, so we parse stdout regardless.
126    let listing = String::from_utf8_lossy(&output.stdout);
127    for line in listing.lines() {
128        let Some(session) = line.split_whitespace().next() else {
129            continue;
130        };
131        // session token looks like "<screen_pid>.winx-<creator_pid>-<ts>"
132        let Some((_, name)) = session.split_once('.') else {
133            continue;
134        };
135        if let Some(creator_pid) = winx_creator_pid(name) {
136            if !process_exists(creator_pid) {
137                let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
138            }
139        }
140    }
141}
142
143/// Extract the creator PID from a `winx-<pid>-<ts>` screen session name.
144fn winx_creator_pid(name: &str) -> Option<u32> {
145    name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
146}
147
148/// Whether a process with `pid` is currently alive (Linux `/proc` check).
149fn process_exists(pid: u32) -> bool {
150    std::path::Path::new("/proc").join(pid.to_string()).exists()
151}
152
153fn timestamp_millis() -> u128 {
154    std::time::SystemTime::now()
155        .duration_since(std::time::UNIX_EPOCH)
156        .map_or(0, |duration| duration.as_millis())
157}
158
159/// Real PTY-based interactive shell
160///
161/// Uses portable-pty for true pseudo-terminal functionality,
162/// enabling proper handling of interactive programs like sudo, vim, etc.
163pub struct PtyShell {
164    /// The PTY master handle for resize operations
165    master: Box<dyn MasterPty + Send>,
166    /// Child process running the shell
167    child: Box<dyn Child + Send + Sync>,
168    /// Writer for PTY input (taken from master)
169    writer: Box<dyn Write + Send>,
170    /// Channel receiver for output from reader thread
171    output_rx: mpsc::Receiver<String>,
172    /// Current terminal size
173    size: PtySize,
174    /// Last command executed
175    pub last_command: String,
176    /// Accumulated output buffer
177    pub output_buffer: String,
178    /// Whether a command is currently running
179    pub command_running: bool,
180    /// Maximum output size before truncation
181    max_output_size: usize,
182    /// Flag for output truncation
183    pub output_truncated: bool,
184    /// Rolling buffer of fully-emitted lines for opt-in scrollback. The newest
185    /// line is at the back; capped at `RING_BUFFER_LINES`.
186    pub line_ring: VecDeque<String>,
187    /// Carries the unterminated tail across reads so partial lines aren't
188    /// double-counted when more bytes arrive.
189    line_ring_partial: String,
190    /// Hash of the last rendered output we shipped to the caller. Used by the
191    /// delta path in `status_check` to elide repeats when the screen is idle.
192    pub last_returned_hash: Option<u64>,
193    /// Optional command a human can run to attach to the same terminal session.
194    pub attach_hint: Option<String>,
195}
196
197impl std::fmt::Debug for PtyShell {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.debug_struct("PtyShell")
200            .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
201            .field("last_command", &self.last_command)
202            .field("command_running", &self.command_running)
203            .field("output_truncated", &self.output_truncated)
204            .field("output_buffer_len", &self.output_buffer.len())
205            .field("attach_hint", &self.attach_hint)
206            .finish_non_exhaustive()
207    }
208}
209
210impl Drop for PtyShell {
211    /// Kill and reap the shell child so it doesn't leak.
212    ///
213    /// `std::process::Child::drop` neither kills nor waits, so without this every
214    /// dropped shell (`reset_shell`, background-shell prune/remove) would leak a
215    /// live bash process — soon a zombie — plus the reader thread blocked in
216    /// `read()`. Killing the child closes the PTY slave, which makes the reader's
217    /// `read()` return EOF so the thread terminates on its own. Best-effort.
218    fn drop(&mut self) {
219        let _ = self.child.kill();
220        let _ = self.child.wait();
221    }
222}
223
224impl PtyShell {
225    /// Create a new PTY shell session
226    ///
227    /// # Arguments
228    /// * `initial_dir` - Starting directory for the shell
229    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
230    ///
231    /// # Returns
232    /// A new `PtyShell` instance with an active bash session
233    pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
234        info!(
235            "Creating new PTY shell (restricted: {}) in {}",
236            restricted_mode,
237            initial_dir.display()
238        );
239
240        // Initialize the native PTY system
241        let pty_system = native_pty_system();
242
243        // Configure terminal size
244        let size =
245            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
246
247        // Open the PTY pair (master + slave)
248        let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
249
250        // Build the command
251        let (mut cmd, attach_hint, is_zsh) = attachable_command(restricted_mode);
252
253        // Set up environment for proper terminal behavior
254        cmd.env("TERM", "xterm-256color");
255        cmd.env("COLORTERM", "truecolor");
256        cmd.env("PAGER", "cat");
257        cmd.env("GIT_PAGER", "cat");
258        cmd.env("COLUMNS", DEFAULT_COLS.to_string());
259        cmd.env("ROWS", DEFAULT_ROWS.to_string());
260        // WCGW-style prompt for command completion detection
261        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
262        cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
263        cmd.cwd(initial_dir);
264
265        // Spawn bash in the PTY slave
266        let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
267
268        // Get reader and writer from master
269        let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
270        let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
271
272        // Create channel for output from reader thread
273        let (output_tx, output_rx) = mpsc::channel::<String>();
274
275        // Spawn a background thread to read from the PTY
276        // This prevents blocking the main thread
277        thread::spawn(move || {
278            let mut buf = [0u8; 4096];
279            loop {
280                match reader.read(&mut buf) {
281                    Ok(0) => {
282                        // EOF - PTY closed
283                        break;
284                    }
285                    Ok(n) => {
286                        let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
287                        if output_tx.send(chunk).is_err() {
288                            // Receiver dropped, exit thread
289                            break;
290                        }
291                    }
292                    Err(e) => {
293                        debug!("PTY reader thread error: {}", e);
294                        break;
295                    }
296                }
297            }
298            debug!("PTY reader thread exiting");
299        });
300
301        // Create the shell instance
302        let mut shell = Self {
303            master: pair.master,
304            child,
305            writer,
306            output_rx,
307            size,
308            last_command: String::new(),
309            output_buffer: String::new(),
310            command_running: false,
311            max_output_size: MAX_OUTPUT_SIZE,
312            output_truncated: false,
313            line_ring: VecDeque::with_capacity(RING_BUFFER_LINES),
314            line_ring_partial: String::new(),
315            last_returned_hash: None,
316            attach_hint,
317        };
318
319        // Initialize the shell with WCGW-style prompt
320        shell.initialize_prompt(is_zsh)?;
321
322        debug!("PTY shell created successfully");
323        Ok(shell)
324    }
325
326    /// Initialize the shell prompt for WCGW compatibility
327    fn initialize_prompt(&mut self, is_zsh: bool) -> Result<()> {
328        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT.
329        // zsh ignores PROMPT_COMMAND, so it gets a precmd hook with a blanked
330        // default prompt instead.
331        // Blank the shell's own PS1/PROMPT so the line ends exactly at our `──➤`
332        // marker. Otherwise a user's ~/.bashrc/~/.zshrc PS1 (e.g. `[user@host]$`)
333        // is appended after the marker, and prompt detection — which anchors on a
334        // trailing `──➤` — only fires when the chunk happens to fragment right
335        // before the PS1, making command-completion detection flaky.
336        let prompt_statement = if is_zsh {
337            // Clear precmd_functions too: frameworks (oh-my-zsh/p10k) register the
338            // prompt via that array, so redefining `precmd` alone leaves their
339            // prompt in place and our `──➤` marker never ends the line.
340            r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() { printf "◉ %s──➤ " "$PWD" }"#
341        } else {
342            r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤ " "$PWD"'; PS1=''"#
343        };
344
345        self.write_command(prompt_statement)?;
346
347        // Wait for prompt to be ready
348        std::thread::sleep(Duration::from_millis(100));
349        let _ = self.drain_output();
350
351        Ok(())
352    }
353
354    /// Write a command to the PTY
355    fn write_command(&mut self, command: &str) -> Result<()> {
356        // Commands in PTY need \r\n for proper terminal behavior
357        let cmd_with_newline = format!("{command}\n");
358        self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
359        self.writer.flush().context("Failed to flush PTY")?;
360        Ok(())
361    }
362
363    /// Drain any pending output from the PTY channel
364    fn drain_output(&mut self) -> String {
365        let mut output = String::new();
366        let deadline = Instant::now() + Duration::from_millis(200);
367
368        // Drain all available output from the channel
369        while Instant::now() < deadline {
370            match self.output_rx.try_recv() {
371                Ok(chunk) => {
372                    output.push_str(&chunk);
373
374                    // Prevent runaway reads
375                    if output.len() > self.max_output_size {
376                        self.output_truncated = true;
377                        break;
378                    }
379                }
380                Err(TryRecvError::Empty) => {
381                    // No more data, wait briefly for more
382                    thread::sleep(Duration::from_millis(10));
383                }
384                Err(TryRecvError::Disconnected) => {
385                    // Reader thread died
386                    break;
387                }
388            }
389        }
390
391        output
392    }
393
394    /// Drain any pending output and, if a previous command still seems to be
395    /// running, send a Ctrl-C to flush it. Mirrors wcgw's `clear_to_run` so a
396    /// new command never inherits stale prompt fragments or a half-typed line.
397    ///
398    /// Returns `true` if the shell looks idle (prompt seen), `false` if it
399    /// still wouldn't yield after the Ctrl-C — caller may want to reset.
400    pub fn clear_to_run(&mut self, max_wait_secs: f32) -> Result<bool> {
401        // Drain whatever is in the channel without blocking. Use the existing
402        // read_output to also catch the prompt fingerprint.
403        let (_, complete) = self.read_output(max_wait_secs.min(0.5))?;
404        if complete {
405            return Ok(true);
406        }
407
408        // Something is still running — interrupt it.
409        debug!("clear_to_run: prompt not seen, sending Ctrl+C");
410        self.send_interrupt()?;
411
412        // Re-drain after the interrupt so the next command starts on a clean prompt.
413        let (_, drained) = self.read_output(max_wait_secs)?;
414        Ok(drained)
415    }
416
417    /// Send a command to the shell and start reading output
418    pub fn send_command(&mut self, command: &str) -> Result<()> {
419        debug!("PTY sending command: {}", command);
420
421        // Clear previous state
422        self.output_buffer.clear();
423        self.output_truncated = false;
424        self.last_command = command.to_string();
425        self.command_running = true;
426        // A new command means the next status_check should return whatever
427        // shows up — drop the dedup hash so we don't elide the first response.
428        self.last_returned_hash = None;
429
430        // Write the command
431        self.write_command(command)?;
432
433        Ok(())
434    }
435
436    /// Push freshly-arrived bytes through the line-oriented ringbuffer so
437    /// callers can request bounded scrollback later.
438    fn ingest_into_ring(&mut self, chunk: &str) {
439        let combined = if self.line_ring_partial.is_empty() {
440            chunk.to_string()
441        } else {
442            let mut s = std::mem::take(&mut self.line_ring_partial);
443            s.push_str(chunk);
444            s
445        };
446
447        let mut last_nl_end: Option<usize> = None;
448        for (idx, ch) in combined.char_indices() {
449            if ch == '\n' {
450                let end = idx + ch.len_utf8();
451                let start = last_nl_end.unwrap_or(0);
452                // Keep the raw line (CR/cursor moves intact); the emulator in
453                // collect_scrollback replays them. Only drop a trailing CR (CRLF).
454                let line = combined[start..idx].trim_end_matches('\r').to_string();
455                if self.line_ring.len() == RING_BUFFER_LINES {
456                    self.line_ring.pop_front();
457                }
458                self.line_ring.push_back(line);
459                last_nl_end = Some(end);
460            }
461        }
462
463        if let Some(end) = last_nl_end {
464            self.line_ring_partial = combined[end..].to_string();
465        } else {
466            self.line_ring_partial = combined;
467        }
468    }
469
470    /// Return up to `lines` recent lines from the ringbuffer, oldest first.
471    /// Includes any in-flight partial line.
472    pub fn collect_scrollback(&self, lines: usize) -> String {
473        if lines == 0 {
474            return String::new();
475        }
476        let start = self.line_ring.len().saturating_sub(lines);
477        let mut out = String::new();
478        for line in self.line_ring.iter().skip(start) {
479            out.push_str(line);
480            out.push('\n');
481        }
482        if !self.line_ring_partial.is_empty() {
483            out.push_str(&self.line_ring_partial);
484        }
485        // The ring holds raw PTY lines. Replay them through the terminal emulator
486        // so cursor movements (readline echo, in-place redraws) are *applied* —
487        // not merely stripped — yielding what the screen actually showed. A plain
488        // strip can't undo a `\x1b[D`, so it would leave `>>> p>>> pr>>> pri...`
489        // echo noise behind; the emulator collapses it to the final line.
490        crate::state::terminal::render_terminal_output(&out).join("\n")
491    }
492
493    /// Hash arbitrary rendered output into a u64 dedup key.
494    pub fn fingerprint(text: &str) -> u64 {
495        let mut hasher = DefaultHasher::new();
496        text.hash(&mut hasher);
497        hasher.finish()
498    }
499
500    /// Read output from the PTY with timeout
501    ///
502    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
503    /// whether the command has finished (prompt detected)
504    pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
505        let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
506        let start = Instant::now();
507        let mut complete = false;
508        let mut no_data_count = 0;
509        let mut prompt_detected_at: Option<Instant> = None;
510
511        while start.elapsed() < timeout {
512            match self.output_rx.try_recv() {
513                Ok(chunk) => {
514                    self.output_buffer.push_str(&chunk);
515                    self.ingest_into_ring(&chunk);
516                    no_data_count = 0;
517
518                    // Check for WCGW prompt indicating command completion
519                    if prompt_detected_at.is_none()
520                        && (Self::check_prompt_complete(&chunk)
521                            || Self::check_prompt_complete(&self.output_buffer))
522                    {
523                        prompt_detected_at = Some(Instant::now());
524                        debug!("Prompt detected, draining remaining output...");
525                    }
526
527                    // Truncate if too large
528                    if self.output_buffer.len() > self.max_output_size {
529                        self.output_truncated = true;
530                        let truncate_msg = "\n(...output truncated...)\n";
531                        let keep_size = self.max_output_size / 2;
532                        // Snap the cut up to a char boundary: a raw byte offset can
533                        // land mid-UTF-8 (CJK/emoji/box-drawing/the prompt glyphs)
534                        // and slicing there would panic on this hot read path.
535                        let mut cut = self.output_buffer.len() - keep_size;
536                        while cut < self.output_buffer.len()
537                            && !self.output_buffer.is_char_boundary(cut)
538                        {
539                            cut += 1;
540                        }
541                        self.output_buffer =
542                            format!("{truncate_msg}{}", &self.output_buffer[cut..]);
543                    }
544                }
545                Err(TryRecvError::Empty) => {
546                    // No data available, wait briefly
547                    thread::sleep(Duration::from_millis(10));
548                    no_data_count += 1;
549
550                    // If prompt was detected, check if we've drained long enough
551                    if let Some(detected_time) = prompt_detected_at {
552                        // Wait 100ms after prompt detection to capture any trailing output
553                        if detected_time.elapsed() > Duration::from_millis(100) {
554                            complete = true;
555                            debug!("Command completed - prompt detected and drained");
556                            break;
557                        }
558                    } else if no_data_count > 10 && Self::check_prompt_complete(&self.output_buffer)
559                    {
560                        // Prompt detected during empty reads
561                        prompt_detected_at = Some(Instant::now());
562                        debug!("Prompt detected after wait, draining...");
563                    }
564                }
565                Err(TryRecvError::Disconnected) => {
566                    // Reader thread died - PTY closed
567                    warn!("PTY reader disconnected");
568                    complete = true;
569                    break;
570                }
571            }
572        }
573
574        if complete || prompt_detected_at.is_some() {
575            self.command_running = false;
576            complete = true;
577        }
578
579        Ok((self.output_buffer.clone(), complete))
580    }
581
582    /// Check if the output contains the WCGW-style prompt
583    fn check_prompt_complete(text: &str) -> bool {
584        // The real shell prompt is the LAST non-empty line: "◉ <pwd>──➤ ".
585        // Anchor on that line instead of a global `contains()`: command output
586        // that happens to print ◉ / ──➤ mid-stream must not be mistaken for the
587        // prompt, which would truncate output or end the command early.
588        text.lines().rev().find(|line| !line.trim().is_empty()).is_some_and(|last| {
589            // Strip ANSI so a trailing erase/cursor sequence after the arrow
590            // (e.g. "──➤ \x1b[K") doesn't defeat the suffix check.
591            let clean = crate::state::terminal::strip_ansi_codes(last);
592            let clean = clean.trim_end();
593            clean.contains(WCGW_PROMPT_PATTERN) && clean.ends_with(WCGW_PROMPT_END)
594        })
595    }
596
597    /// Send Ctrl+C (interrupt) to the PTY
598    pub fn send_interrupt(&mut self) -> Result<()> {
599        debug!("PTY sending Ctrl+C");
600        self.writer
601            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
602            .context("Failed to send Ctrl+C")?;
603        self.writer.flush()?;
604        Ok(())
605    }
606
607    /// Send Ctrl+D (EOF) to the PTY
608    pub fn send_eof(&mut self) -> Result<()> {
609        debug!("PTY sending Ctrl+D");
610        self.writer
611            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
612            .context("Failed to send Ctrl+D")?;
613        self.writer.flush()?;
614        Ok(())
615    }
616
617    /// Send Ctrl+Z (suspend) to the PTY
618    pub fn send_suspend(&mut self) -> Result<()> {
619        debug!("PTY sending Ctrl+Z");
620        self.writer
621            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
622            .context("Failed to send Ctrl+Z")?;
623        self.writer.flush()?;
624        Ok(())
625    }
626
627    /// Send text directly to the PTY (for interactive input)
628    pub fn send_text(&mut self, text: &str) -> Result<()> {
629        debug!("PTY sending text: {:?}", text);
630        self.send_bytes(text.as_bytes()).context("Failed to send text")?;
631        Ok(())
632    }
633
634    /// Send raw bytes directly to the PTY.
635    pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
636        self.writer.write_all(bytes).context("Failed to send bytes")?;
637        self.writer.flush()?;
638        Ok(())
639    }
640
641    /// Send a special key sequence
642    pub fn send_special_key(&mut self, key: &str) -> Result<()> {
643        let bytes: &[u8] = match key {
644            "Enter" => b"\r",
645            "Tab" => b"\t",
646            "Backspace" => b"\x7F",
647            "Escape" => b"\x1B",
648            "Up" | "KeyUp" => b"\x1B[A",
649            "Down" | "KeyDown" => b"\x1B[B",
650            "Right" | "KeyRight" => b"\x1B[C",
651            "Left" | "KeyLeft" => b"\x1B[D",
652            "Home" => b"\x1B[H",
653            "End" => b"\x1B[F",
654            "PageUp" => b"\x1B[5~",
655            "PageDown" => b"\x1B[6~",
656            "Delete" => b"\x1B[3~",
657            "Insert" => b"\x1B[2~",
658            "CtrlC" | "Ctrl-C" => b"\x03",
659            "CtrlD" | "Ctrl-D" => b"\x04",
660            "CtrlZ" | "Ctrl-Z" => b"\x1A",
661            "CtrlL" | "Ctrl-L" => b"\x0C",
662            _ => return Err(anyhow!("Unknown special key: {key}")),
663        };
664
665        debug!("PTY sending special key: {} ({:?})", key, bytes);
666        self.send_bytes(bytes)?;
667        Ok(())
668    }
669
670    /// Resize the terminal
671    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
672        debug!("PTY resizing to {}x{}", cols, rows);
673
674        let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
675
676        self.master.resize(new_size).context("Failed to resize PTY")?;
677
678        self.size = new_size;
679        Ok(())
680    }
681
682    /// Get current terminal size
683    pub fn get_size(&self) -> (u16, u16) {
684        (self.size.cols, self.size.rows)
685    }
686
687    /// Check if the shell is still alive
688    pub fn is_alive(&mut self) -> bool {
689        self.child.try_wait().is_ok_and(|status| status.is_none())
690    }
691}
692
693/// Thread-safe wrapper for `PtyShell`
694pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
695
696/// Create a new shared PTY shell
697pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
698    let shell = PtyShell::new(initial_dir, restricted_mode)?;
699    Ok(Arc::new(Mutex::new(Some(shell))))
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use tempfile::TempDir;
706
707    #[test]
708    fn prompt_detection_is_suffix_anchored() {
709        // real prompt on the last line -> complete
710        assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤ "));
711        // prompt with trailing ANSI erase -> still complete
712        assert!(PtyShell::check_prompt_complete("◉ /home/x──➤ \u{1b}[K"));
713        // the bug: ◉ and ──➤ appear MID-output, last line is normal -> NOT complete
714        assert!(!PtyShell::check_prompt_complete("menu: ◉ start ──➤ stop\nstill running"));
715        // command echoed after the arrow (not the waiting prompt) -> not complete
716        assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤ ls -la"));
717        // no prompt at all
718        assert!(!PtyShell::check_prompt_complete("just some output\n"));
719    }
720
721    #[test]
722    fn test_pty_shell_creation() -> Result<()> {
723        let temp_dir = TempDir::new()?;
724        let result = PtyShell::new(temp_dir.path(), false);
725        assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
726        Ok(())
727    }
728
729    #[test]
730    fn test_pty_shell_echo() -> Result<()> {
731        let temp_dir = TempDir::new()?;
732        let mut shell = PtyShell::new(temp_dir.path(), false)?;
733
734        shell.send_command("echo 'hello pty'")?;
735        let (output, _complete) = shell.read_output(2.0)?;
736
737        assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
738        Ok(())
739    }
740
741    #[test]
742    fn test_pty_shell_pwd() -> Result<()> {
743        let temp_dir = TempDir::new()?;
744        let mut shell = PtyShell::new(temp_dir.path(), false)?;
745
746        // Simply verify shell responds to pwd command
747        // Use single quotes like echo test for consistency
748        shell.send_command("pwd && echo 'pwd_done'")?;
749        let (output, _complete) = shell.read_output(2.0)?;
750
751        // Verify the echo marker appears (proves command executed)
752        assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
753        Ok(())
754    }
755
756    #[test]
757    fn test_pty_resize() -> Result<()> {
758        let temp_dir = TempDir::new()?;
759        let mut shell = PtyShell::new(temp_dir.path(), false)?;
760
761        let result = shell.resize(120, 40);
762        assert!(result.is_ok());
763
764        let (cols, rows) = shell.get_size();
765        assert_eq!(cols, 120);
766        assert_eq!(rows, 40);
767        Ok(())
768    }
769}