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}