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