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    /// Maximum output size before truncation
188    max_output_size: usize,
189    /// Flag for output truncation
190    pub output_truncated: bool,
191    /// Bounded scrollback ring of raw PTY lines (newest at the back) plus the
192    /// in-flight partial line, with drop accounting so a truncated scrollback is
193    /// reported to the caller instead of silently shrinking. See `LineRing`.
194    ring: LineRing,
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    /// Live terminal emulator (fixed-viewport, `vt100`-backed) fed continuously
207    /// by the reader thread. Unlike the scrollback ring (raw lines), it keeps a
208    /// consolidated screen grid — cursor moves, redraws, alternate-screen
209    /// applied — so a TUI yields a stable, non-stacked snapshot. The fixed
210    /// viewport is what lets inline-rendering TUIs (the `agy`/Antigravity CLI,
211    /// which redraws with cursor-up + erase-line in the main screen) consolidate
212    /// instead of ghosting. See `live_snapshot`.
213    live: Arc<StdMutex<LiveTerminal>>,
214}
215
216impl std::fmt::Debug for PtyShell {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("PtyShell")
219            .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
220            .field("last_command", &self.last_command)
221            .field("command_running", &self.command_running)
222            .field("output_truncated", &self.output_truncated)
223            .field("output_buffer_len", &self.output_buffer.len())
224            .field("attach_hint", &self.attach_hint)
225            .finish_non_exhaustive()
226    }
227}
228
229impl Drop for PtyShell {
230    /// Kill and reap the shell child so it doesn't leak.
231    ///
232    /// `std::process::Child::drop` neither kills nor waits, so without this every
233    /// dropped shell (`reset_shell`, background-shell prune/remove) would leak a
234    /// live bash process — soon a zombie — plus the reader thread blocked in
235    /// `read()`. Killing the child closes the PTY slave, which makes the reader's
236    /// `read()` return EOF so the thread terminates on its own. Best-effort.
237    fn drop(&mut self) {
238        let _ = self.child.kill();
239        let _ = self.child.wait();
240    }
241}
242
243impl PtyShell {
244    /// Create a new PTY shell session
245    ///
246    /// # Arguments
247    /// * `initial_dir` - Starting directory for the shell
248    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
249    ///
250    /// # Returns
251    /// A new `PtyShell` instance with an active bash session
252    pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
253        info!(
254            "Creating new PTY shell (restricted: {}) in {}",
255            restricted_mode,
256            initial_dir.display()
257        );
258
259        // Initialize the native PTY system
260        let pty_system = native_pty_system();
261
262        // Configure terminal size
263        let size =
264            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
265
266        // Open the PTY pair (master + slave)
267        let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
268
269        // Build the command
270        let (mut cmd, attach_hint, is_zsh) = attachable_command(restricted_mode);
271
272        // Set up environment for proper terminal behavior
273        cmd.env("TERM", "xterm-256color");
274        cmd.env("COLORTERM", "truecolor");
275        cmd.env("PAGER", "cat");
276        cmd.env("GIT_PAGER", "cat");
277        cmd.env("COLUMNS", DEFAULT_COLS.to_string());
278        cmd.env("ROWS", DEFAULT_ROWS.to_string());
279        // Per-shell random nonce embedded in the prompt so command output can't
280        // forge the completion marker. 64 bits of entropy in hex.
281        let nonce = format!("{:016x}", rand::random::<u64>());
282        // WCGW-style prompt for command completion detection
283        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
284        cmd.env("PROMPT_COMMAND", format!(r#"printf "◉ %s──➤{nonce} " "$PWD""#));
285        cmd.cwd(initial_dir);
286
287        // Spawn bash in the PTY slave
288        let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
289
290        // Get reader and writer from master
291        let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
292        let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
293
294        // Bounded channel: if the consumer stalls, the reader thread blocks on
295        // send (natural backpressure via the PTY OS buffer) instead of growing
296        // memory without bound on a runaway producer like `yes`.
297        let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
298
299        // Live terminal emulator, shared with the reader thread so the screen
300        // grid stays current without any consumer needing to poll.
301        let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
302        let live_reader = Arc::clone(&live);
303
304        // Spawn a background thread to read from the PTY
305        // This prevents blocking the main thread
306        thread::spawn(move || {
307            let mut buf = [0u8; 4096];
308            loop {
309                match reader.read(&mut buf) {
310                    Ok(0) => {
311                        // EOF - PTY closed
312                        break;
313                    }
314                    Ok(n) => {
315                        // Tap the raw bytes into the live emulator first (brief
316                        // lock; feed is O(chunk len)). Feeding bytes — not the
317                        // lossy String — keeps the persistent VTE parser exact
318                        // across chunk boundaries.
319                        if let Ok(mut emu) = live_reader.lock() {
320                            emu.feed(&buf[..n]);
321                        }
322                        let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
323                        if output_tx.send(chunk).is_err() {
324                            // Receiver dropped, exit thread
325                            break;
326                        }
327                    }
328                    Err(e) => {
329                        debug!("PTY reader thread error: {}", e);
330                        break;
331                    }
332                }
333            }
334            debug!("PTY reader thread exiting");
335        });
336
337        // Create the shell instance
338        let mut shell = Self {
339            master: pair.master,
340            child,
341            writer,
342            output_rx,
343            size,
344            last_command: String::new(),
345            output_buffer: String::new(),
346            command_running: false,
347            max_output_size: MAX_OUTPUT_SIZE,
348            output_truncated: false,
349            ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
350            last_returned_hash: None,
351            attach_hint,
352            prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
353            live,
354        };
355
356        // Initialize the shell with WCGW-style prompt
357        shell.initialize_prompt(is_zsh, &nonce)?;
358
359        debug!("PTY shell created successfully");
360        Ok(shell)
361    }
362
363    /// Initialize the shell prompt for WCGW compatibility
364    fn initialize_prompt(&mut self, is_zsh: bool, nonce: &str) -> Result<()> {
365        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT.
366        // zsh ignores PROMPT_COMMAND, so it gets a precmd hook with a blanked
367        // default prompt instead.
368        // Blank the shell's own PS1/PROMPT so the line ends exactly at our `──➤`
369        // marker. Otherwise a user's ~/.bashrc/~/.zshrc PS1 (e.g. `[user@host]$`)
370        // is appended after the marker, and prompt detection — which anchors on a
371        // trailing `──➤` — only fires when the chunk happens to fragment right
372        // before the PS1, making command-completion detection flaky.
373        let prompt_statement = if is_zsh {
374            // Clear precmd_functions too: frameworks (oh-my-zsh/p10k) register the
375            // prompt via that array, so redefining `precmd` alone leaves their
376            // prompt in place and our `──➤` marker never ends the line.
377            format!(
378                r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ printf "◉ %s──➤{nonce} " "$PWD" }}"#
379            )
380        } else {
381            format!(
382                r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤{nonce} " "$PWD"'; PS1=''"#
383            )
384        };
385
386        self.write_command(&prompt_statement)?;
387
388        // Wait for prompt to be ready
389        std::thread::sleep(Duration::from_millis(100));
390        let _ = self.drain_output();
391
392        Ok(())
393    }
394
395    /// Write a command to the PTY, submitting it with a carriage return.
396    fn write_command(&mut self, command: &str) -> Result<()> {
397        // Submit with `\r`, matching the foreground path (`send_special_key("Enter")`
398        // also sends `\r`) and a real terminal's Enter key. The two paths used to
399        // disagree — background sent `\n` here, foreground sent `\r` — which is a
400        // real difference for readline-based TUIs. bash's canonical line discipline
401        // maps CR to NL (ICRNL), so plain commands behave identically.
402        let cmd_with_newline = format!("{command}\r");
403        self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
404        self.writer.flush().context("Failed to flush PTY")?;
405        Ok(())
406    }
407
408    /// Drain any pending output from the PTY channel
409    fn drain_output(&mut self) -> String {
410        let mut output = String::new();
411        let deadline = Instant::now() + Duration::from_millis(200);
412
413        // Drain all available output from the channel
414        while Instant::now() < deadline {
415            match self.output_rx.try_recv() {
416                Ok(chunk) => {
417                    output.push_str(&chunk);
418
419                    // Prevent runaway reads
420                    if output.len() > self.max_output_size {
421                        self.output_truncated = true;
422                        break;
423                    }
424                }
425                Err(TryRecvError::Empty) => {
426                    // No more data, wait briefly for more
427                    thread::sleep(Duration::from_millis(10));
428                }
429                Err(TryRecvError::Disconnected) => {
430                    // Reader thread died
431                    break;
432                }
433            }
434        }
435
436        output
437    }
438
439    /// Drain any pending output and, if a previous command still seems to be
440    /// running, send a Ctrl-C to flush it. Mirrors wcgw's `clear_to_run` so a
441    /// new command never inherits stale prompt fragments or a half-typed line.
442    ///
443    /// Returns `true` if the shell looks idle (prompt seen), `false` if it
444    /// still wouldn't yield after the Ctrl-C — caller may want to reset.
445    pub fn clear_to_run(&mut self, max_wait_secs: f32) -> Result<bool> {
446        // Drain whatever is in the channel without blocking. Use the existing
447        // read_output to also catch the prompt fingerprint.
448        let (_, complete) = self.read_output(max_wait_secs.min(0.5))?;
449        if complete {
450            return Ok(true);
451        }
452
453        // Something is still running — interrupt it.
454        debug!("clear_to_run: prompt not seen, sending Ctrl+C");
455        self.send_interrupt()?;
456
457        // Re-drain after the interrupt so the next command starts on a clean prompt.
458        let (_, drained) = self.read_output(max_wait_secs)?;
459        Ok(drained)
460    }
461
462    /// Send a command to the shell and start reading output
463    pub fn send_command(&mut self, command: &str) -> Result<()> {
464        debug!("PTY sending command: {}", command);
465
466        // Clear previous state
467        self.output_buffer.clear();
468        self.output_truncated = false;
469        self.last_command = command.to_string();
470        self.command_running = true;
471        // A new command means the next status_check should return whatever
472        // shows up — drop the dedup hash so we don't elide the first response.
473        self.last_returned_hash = None;
474
475        // Write the command
476        self.write_command(command)?;
477
478        Ok(())
479    }
480
481    /// Return up to `lines` recent lines from the scrollback ring, oldest
482    /// first. When older lines were evicted or a long unterminated line was
483    /// clipped, a `[winx: …]` notice is prepended so the caller knows the
484    /// scrollback is incomplete rather than silently receiving less than asked.
485    pub fn collect_scrollback(&self, lines: usize) -> String {
486        // The ring holds raw PTY lines. Replay them through the terminal
487        // emulator so cursor movements (readline echo, in-place redraws) are
488        // *applied* — not merely stripped — yielding what the screen actually
489        // showed. A plain strip can't undo a `\x1b[D`, so it would leave
490        // `>>> p>>> pr>>> pri...` echo noise behind; the emulator collapses it.
491        let rendered =
492            crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
493        match self.ring.scrollback_notice(lines) {
494            Some(notice) => format!("{notice}\n{rendered}"),
495            None => rendered,
496        }
497    }
498
499    /// Hash arbitrary rendered output into a u64 dedup key.
500    pub fn fingerprint(text: &str) -> u64 {
501        let mut hasher = DefaultHasher::new();
502        text.hash(&mut hasher);
503        hasher.finish()
504    }
505
506    /// Read output from the PTY with timeout
507    ///
508    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
509    /// whether the command has finished (prompt detected)
510    pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
511        let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
512        let start = Instant::now();
513        let mut complete = false;
514        let mut no_data_count = 0;
515        let mut prompt_detected_at: Option<Instant> = None;
516
517        while start.elapsed() < timeout {
518            match self.output_rx.try_recv() {
519                Ok(chunk) => {
520                    self.output_buffer.push_str(&chunk);
521                    self.ring.push_chunk(&chunk);
522                    no_data_count = 0;
523
524                    // Check for WCGW prompt indicating command completion
525                    if prompt_detected_at.is_none()
526                        && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
527                            || Self::check_prompt_complete(
528                                &self.output_buffer,
529                                &self.prompt_end_marker,
530                            ))
531                    {
532                        prompt_detected_at = Some(Instant::now());
533                        debug!("Prompt detected, draining remaining output...");
534                    }
535
536                    // Truncate if too large
537                    if self.output_buffer.len() > self.max_output_size {
538                        self.output_truncated = true;
539                        let truncate_msg = "\n(...output truncated...)\n";
540                        let keep_size = self.max_output_size / 2;
541                        // Snap to a char boundary: a raw byte offset can land mid-UTF-8
542                        // (CJK/emoji/box-drawing/the prompt glyphs) and slicing there would
543                        // panic on this hot read path. Same helper as the poll-drain path.
544                        let cut = crate::utils::floor_char_boundary(
545                            &self.output_buffer,
546                            self.output_buffer.len() - keep_size,
547                        );
548                        self.output_buffer =
549                            format!("{truncate_msg}{}", &self.output_buffer[cut..]);
550                    }
551                }
552                Err(TryRecvError::Empty) => {
553                    // No data available, wait briefly
554                    thread::sleep(Duration::from_millis(10));
555                    no_data_count += 1;
556
557                    // If prompt was detected, check if we've drained long enough
558                    if let Some(detected_time) = prompt_detected_at {
559                        // Wait 100ms after prompt detection to capture any trailing output
560                        if detected_time.elapsed() > Duration::from_millis(100) {
561                            complete = true;
562                            debug!("Command completed - prompt detected and drained");
563                            break;
564                        }
565                    } else if no_data_count > 10
566                        && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
567                    {
568                        // Prompt detected during empty reads
569                        prompt_detected_at = Some(Instant::now());
570                        debug!("Prompt detected after wait, draining...");
571                    }
572                }
573                Err(TryRecvError::Disconnected) => {
574                    // Reader thread died - PTY closed
575                    warn!("PTY reader disconnected");
576                    complete = true;
577                    break;
578                }
579            }
580        }
581
582        if complete || prompt_detected_at.is_some() {
583            self.command_running = false;
584            complete = true;
585        }
586
587        Ok((self.output_buffer.clone(), complete))
588    }
589
590    /// Non-blocking drain of whatever the reader thread has already queued.
591    ///
592    /// Unlike [`read_output`](Self::read_output), this never sleeps: it consumes
593    /// every chunk currently available, updates prompt/completion state, and
594    /// returns immediately. `prune_finished_shells` uses it so the global
595    /// background-shell lock is never held across a blocking 100ms `read_output`.
596    /// Returns `true` if the command has completed (prompt seen or PTY closed).
597    pub fn poll_output_nonblocking(&mut self) -> bool {
598        let mut prompt_seen = false;
599        loop {
600            match self.output_rx.try_recv() {
601                Ok(chunk) => {
602                    let chunk_has_prompt =
603                        Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
604                    self.output_buffer.push_str(&chunk);
605                    self.ring.push_chunk(&chunk);
606                    if chunk_has_prompt
607                        || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
608                    {
609                        prompt_seen = true;
610                    }
611                    if self.output_buffer.len() > self.max_output_size {
612                        self.output_truncated = true;
613                        let truncate_msg = "\n(...output truncated...)\n";
614                        let keep_size = self.max_output_size / 2;
615                        let cut = crate::utils::floor_char_boundary(
616                            &self.output_buffer,
617                            self.output_buffer.len() - keep_size,
618                        );
619                        self.output_buffer =
620                            format!("{truncate_msg}{}", &self.output_buffer[cut..]);
621                    }
622                }
623                Err(TryRecvError::Empty) => break,
624                Err(TryRecvError::Disconnected) => {
625                    self.command_running = false;
626                    return true;
627                }
628            }
629        }
630        if prompt_seen {
631            self.command_running = false;
632        }
633        prompt_seen
634    }
635
636    /// Check if the output ends with this shell's prompt.
637    ///
638    /// `prompt_end` is the per-shell `──➤<nonce>` suffix. Anchoring on the LAST
639    /// non-empty line (not a global `contains`) plus the random nonce means
640    /// command output that happens to print the prompt glyphs mid-stream — or
641    /// even a forged `◉ x──➤ ` at end of line — can't be mistaken for the prompt
642    /// and truncate output or end the command early.
643    fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
644        text.lines().rev().find(|line| !line.trim().is_empty()).is_some_and(|last| {
645            // Strip ANSI so a trailing erase/cursor sequence after the arrow
646            // (e.g. "──➤ \x1b[K") doesn't defeat the suffix check.
647            let clean = crate::state::terminal::strip_ansi_codes(last);
648            let clean = clean.trim_end();
649            clean.contains(WCGW_PROMPT_PATTERN) && clean.ends_with(prompt_end)
650        })
651    }
652
653    /// Send Ctrl+C (interrupt) to the PTY
654    pub fn send_interrupt(&mut self) -> Result<()> {
655        debug!("PTY sending Ctrl+C");
656        self.writer
657            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
658            .context("Failed to send Ctrl+C")?;
659        self.writer.flush()?;
660        Ok(())
661    }
662
663    /// Send Ctrl+D (EOF) to the PTY
664    pub fn send_eof(&mut self) -> Result<()> {
665        debug!("PTY sending Ctrl+D");
666        self.writer
667            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
668            .context("Failed to send Ctrl+D")?;
669        self.writer.flush()?;
670        Ok(())
671    }
672
673    /// Send Ctrl+Z (suspend) to the PTY
674    pub fn send_suspend(&mut self) -> Result<()> {
675        debug!("PTY sending Ctrl+Z");
676        self.writer
677            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
678            .context("Failed to send Ctrl+Z")?;
679        self.writer.flush()?;
680        Ok(())
681    }
682
683    /// Send text directly to the PTY (for interactive input)
684    pub fn send_text(&mut self, text: &str) -> Result<()> {
685        debug!("PTY sending text: {:?}", text);
686        self.send_bytes(text.as_bytes()).context("Failed to send text")?;
687        Ok(())
688    }
689
690    /// Send raw bytes directly to the PTY.
691    pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
692        self.writer.write_all(bytes).context("Failed to send bytes")?;
693        self.writer.flush()?;
694        Ok(())
695    }
696
697    /// Send a special key sequence
698    pub fn send_special_key(&mut self, key: &str) -> Result<()> {
699        let bytes: &[u8] = match key {
700            "Enter" => b"\r",
701            "Tab" => b"\t",
702            "Backspace" => b"\x7F",
703            "Escape" => b"\x1B",
704            "Up" | "KeyUp" => b"\x1B[A",
705            "Down" | "KeyDown" => b"\x1B[B",
706            "Right" | "KeyRight" => b"\x1B[C",
707            "Left" | "KeyLeft" => b"\x1B[D",
708            "Home" => b"\x1B[H",
709            "End" => b"\x1B[F",
710            "PageUp" => b"\x1B[5~",
711            "PageDown" => b"\x1B[6~",
712            "Delete" => b"\x1B[3~",
713            "Insert" => b"\x1B[2~",
714            "CtrlC" | "Ctrl-C" => b"\x03",
715            "CtrlD" | "Ctrl-D" => b"\x04",
716            "CtrlZ" | "Ctrl-Z" => b"\x1A",
717            "CtrlL" | "Ctrl-L" => b"\x0C",
718            _ => return Err(anyhow!("Unknown special key: {key}")),
719        };
720
721        debug!("PTY sending special key: {} ({:?})", key, bytes);
722        self.send_bytes(bytes)?;
723        Ok(())
724    }
725
726    /// Snapshot the live terminal screen — a stable, consolidated view of what a
727    /// human would currently see, with cursor moves/redraws applied and ANSI
728    /// stripped. `max_lines` of 0 returns the full screen buffer. This is the
729    /// foundation for piloting interactive TUIs (the `claude` CLI, vim, ...).
730    pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
731        match self.live.lock() {
732            Ok(emu) => emu.snapshot(max_lines),
733            Err(_) => Vec::new(),
734        }
735    }
736
737    /// Whether the live terminal is currently on the alternate screen buffer
738    /// (a full-screen app like vim/htop/less is running).
739    pub fn live_in_alt_screen(&self) -> bool {
740        self.live.lock().is_ok_and(|emu| emu.in_alt_screen())
741    }
742
743    /// Cursor position `(row, col)` on the live screen (0-based), so a piloting
744    /// agent knows where focus is in a menu/form. `(0, 0)` if the lock is poisoned.
745    pub fn live_cursor_position(&self) -> (u16, u16) {
746        self.live.lock().map_or((0, 0), |emu| emu.cursor_position())
747    }
748
749    /// Diff the live screen against what the client last saw, returning only the
750    /// changed lines when the change is small (huge token savings over many
751    /// polls). Updates the baseline as a side effect. See [`ScreenUpdate`].
752    pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
753        match self.live.lock() {
754            Ok(mut emu) => emu.snapshot_diff(max_lines, threshold),
755            Err(_) => ScreenUpdate::Full(Vec::new()),
756        }
757    }
758
759    /// Resize the terminal
760    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
761        debug!("PTY resizing to {}x{}", cols, rows);
762
763        let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
764
765        self.master.resize(new_size).context("Failed to resize PTY")?;
766
767        // Keep the live emulator's viewport in lockstep with the PTY size.
768        if let Ok(mut emu) = self.live.lock() {
769            emu.resize(rows, cols);
770        }
771
772        self.size = new_size;
773        Ok(())
774    }
775
776    /// Get current terminal size
777    pub fn get_size(&self) -> (u16, u16) {
778        (self.size.cols, self.size.rows)
779    }
780
781    /// Check if the shell is still alive
782    pub fn is_alive(&mut self) -> bool {
783        self.child.try_wait().is_ok_and(|status| status.is_none())
784    }
785}
786
787/// Thread-safe wrapper for `PtyShell`
788pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
789
790/// Create a new shared PTY shell
791pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
792    let shell = PtyShell::new(initial_dir, restricted_mode)?;
793    Ok(Arc::new(Mutex::new(Some(shell))))
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use tempfile::TempDir;
800
801    #[test]
802    fn prompt_detection_is_suffix_anchored() {
803        // Per-shell nonce appended after the arrow.
804        let end = "──➤deadbeefcafe0001";
805        // real prompt on the last line -> complete
806        assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001 ", end));
807        // prompt with trailing ANSI erase -> still complete
808        assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 \u{1b}[K", end));
809        // the bug: ◉ and ──➤ appear MID-output, last line is normal -> NOT complete
810        assert!(!PtyShell::check_prompt_complete(
811            "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
812            end
813        ));
814        // command echoed after the arrow (not the waiting prompt) -> not complete
815        assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ls -la", end));
816        // no prompt at all
817        assert!(!PtyShell::check_prompt_complete("just some output\n", end));
818        // the nonce's payoff: output that forges the bare glyphs but NOT the
819        // nonce must not be mistaken for the prompt.
820        assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
821        assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce ", end));
822    }
823
824    #[test]
825    fn test_pty_shell_creation() -> Result<()> {
826        let temp_dir = TempDir::new()?;
827        let result = PtyShell::new(temp_dir.path(), false);
828        assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
829        Ok(())
830    }
831
832    #[test]
833    fn test_pty_shell_echo() -> Result<()> {
834        let temp_dir = TempDir::new()?;
835        let mut shell = PtyShell::new(temp_dir.path(), false)?;
836
837        shell.send_command("echo 'hello pty'")?;
838        let (output, _complete) = shell.read_output(2.0)?;
839
840        assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
841        Ok(())
842    }
843
844    #[test]
845    fn test_pty_shell_pwd() -> Result<()> {
846        let temp_dir = TempDir::new()?;
847        let mut shell = PtyShell::new(temp_dir.path(), false)?;
848
849        // Simply verify shell responds to pwd command
850        // Use single quotes like echo test for consistency
851        shell.send_command("pwd && echo 'pwd_done'")?;
852        let (output, _complete) = shell.read_output(2.0)?;
853
854        // Verify the echo marker appears (proves command executed)
855        assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
856        Ok(())
857    }
858
859    #[test]
860    fn test_pty_resize() -> Result<()> {
861        let temp_dir = TempDir::new()?;
862        let mut shell = PtyShell::new(temp_dir.path(), false)?;
863
864        let result = shell.resize(120, 40);
865        assert!(result.is_ok());
866
867        let (cols, rows) = shell.get_size();
868        assert_eq!(cols, 120);
869        assert_eq!(rows, 40);
870        Ok(())
871    }
872}