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}