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