1use anyhow::{anyhow, Context, Result};
11use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
12use std::collections::hash_map::DefaultHasher;
13use std::collections::VecDeque;
14use std::hash::{Hash, Hasher};
15use std::io::{Read, Write};
16use std::path::Path;
17use std::process::Command;
18use std::sync::mpsc::{self, TryRecvError};
19use std::sync::Arc;
20use std::thread;
21use std::time::{Duration, Instant};
22use tokio::sync::Mutex;
23use tracing::{debug, info, warn};
24
25pub const DEFAULT_COLS: u16 = 200;
27pub const DEFAULT_ROWS: u16 = 50;
28
29const MAX_OUTPUT_SIZE: usize = 1_000_000;
31
32pub const RING_BUFFER_LINES: usize = 2_000;
36
37const WCGW_PROMPT_PATTERN: &str = "◉";
39const WCGW_PROMPT_END: &str = "──➤";
40
41fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, bool) {
42 let requested = std::env::var("WINX_ATTACH_TERMINAL")
43 .or_else(|_| std::env::var("WINX_USE_SCREEN"))
44 .unwrap_or_default();
45 if !requested.is_empty() && requested != "0" && requested != "false" {
46 let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
47 if requested == "tmux" && command_available("tmux") {
48 let mut cmd = CommandBuilder::new("tmux");
49 cmd.args(["new-session", "-A", "-s", &session, "bash"]);
50 if restricted_mode {
51 cmd.arg("-r");
52 }
53 return (cmd, Some(format!("tmux attach -t {session}")), false);
54 }
55 if command_available("screen") {
56 ensure_screenrc();
59 cleanup_orphaned_screens();
60 let mut cmd = CommandBuilder::new("screen");
61 cmd.args(["-q", "-S", &session, "bash"]);
62 if restricted_mode {
63 cmd.arg("-r");
64 }
65 return (cmd, Some(format!("screen -x {session}")), false);
66 }
67 }
68
69 let shell = preferred_shell(restricted_mode);
70 let is_zsh = shell == "zsh";
71 let mut cmd = CommandBuilder::new(&shell);
72 if restricted_mode && !is_zsh {
74 cmd.arg("-r");
75 }
76 (cmd, None, is_zsh)
77}
78
79fn preferred_shell(restricted_mode: bool) -> String {
83 if !restricted_mode {
84 if let Ok(requested) = std::env::var("WINX_SHELL") {
85 if requested == "zsh" && command_available("zsh") {
86 return "zsh".to_string();
87 }
88 }
89 }
90 "bash".to_string()
91}
92
93fn command_available(command: &str) -> bool {
94 Command::new("sh")
95 .args(["-c", &format!("command -v {command}")])
96 .output()
97 .is_ok_and(|output| output.status.success())
98}
99
100fn ensure_screenrc() {
103 let Some(home) = home::home_dir() else {
104 return;
105 };
106 let screenrc = home.join(".screenrc");
107 if screenrc.exists() {
108 return;
109 }
110 let _ = std::fs::write(
111 &screenrc,
112 "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
113 );
114}
115
116fn cleanup_orphaned_screens() {
122 let Ok(output) = Command::new("screen").arg("-ls").output() else {
123 return;
124 };
125 let listing = String::from_utf8_lossy(&output.stdout);
127 for line in listing.lines() {
128 let Some(session) = line.split_whitespace().next() else {
129 continue;
130 };
131 let Some((_, name)) = session.split_once('.') else {
133 continue;
134 };
135 if let Some(creator_pid) = winx_creator_pid(name) {
136 if !process_exists(creator_pid) {
137 let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
138 }
139 }
140 }
141}
142
143fn winx_creator_pid(name: &str) -> Option<u32> {
145 name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
146}
147
148fn process_exists(pid: u32) -> bool {
150 std::path::Path::new("/proc").join(pid.to_string()).exists()
151}
152
153fn timestamp_millis() -> u128 {
154 std::time::SystemTime::now()
155 .duration_since(std::time::UNIX_EPOCH)
156 .map_or(0, |duration| duration.as_millis())
157}
158
159pub struct PtyShell {
164 master: Box<dyn MasterPty + Send>,
166 child: Box<dyn Child + Send + Sync>,
168 writer: Box<dyn Write + Send>,
170 output_rx: mpsc::Receiver<String>,
172 size: PtySize,
174 pub last_command: String,
176 pub output_buffer: String,
178 pub command_running: bool,
180 max_output_size: usize,
182 pub output_truncated: bool,
184 pub line_ring: VecDeque<String>,
187 line_ring_partial: String,
190 pub last_returned_hash: Option<u64>,
193 pub attach_hint: Option<String>,
195}
196
197impl std::fmt::Debug for PtyShell {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 f.debug_struct("PtyShell")
200 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
201 .field("last_command", &self.last_command)
202 .field("command_running", &self.command_running)
203 .field("output_truncated", &self.output_truncated)
204 .field("output_buffer_len", &self.output_buffer.len())
205 .field("attach_hint", &self.attach_hint)
206 .finish_non_exhaustive()
207 }
208}
209
210impl Drop for PtyShell {
211 fn drop(&mut self) {
219 let _ = self.child.kill();
220 let _ = self.child.wait();
221 }
222}
223
224impl PtyShell {
225 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
234 info!(
235 "Creating new PTY shell (restricted: {}) in {}",
236 restricted_mode,
237 initial_dir.display()
238 );
239
240 let pty_system = native_pty_system();
242
243 let size =
245 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
246
247 let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
249
250 let (mut cmd, attach_hint, is_zsh) = attachable_command(restricted_mode);
252
253 cmd.env("TERM", "xterm-256color");
255 cmd.env("COLORTERM", "truecolor");
256 cmd.env("PAGER", "cat");
257 cmd.env("GIT_PAGER", "cat");
258 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
259 cmd.env("ROWS", DEFAULT_ROWS.to_string());
260 cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
263 cmd.cwd(initial_dir);
264
265 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
267
268 let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
270 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
271
272 let (output_tx, output_rx) = mpsc::channel::<String>();
274
275 thread::spawn(move || {
278 let mut buf = [0u8; 4096];
279 loop {
280 match reader.read(&mut buf) {
281 Ok(0) => {
282 break;
284 }
285 Ok(n) => {
286 let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
287 if output_tx.send(chunk).is_err() {
288 break;
290 }
291 }
292 Err(e) => {
293 debug!("PTY reader thread error: {}", e);
294 break;
295 }
296 }
297 }
298 debug!("PTY reader thread exiting");
299 });
300
301 let mut shell = Self {
303 master: pair.master,
304 child,
305 writer,
306 output_rx,
307 size,
308 last_command: String::new(),
309 output_buffer: String::new(),
310 command_running: false,
311 max_output_size: MAX_OUTPUT_SIZE,
312 output_truncated: false,
313 line_ring: VecDeque::with_capacity(RING_BUFFER_LINES),
314 line_ring_partial: String::new(),
315 last_returned_hash: None,
316 attach_hint,
317 };
318
319 shell.initialize_prompt(is_zsh)?;
321
322 debug!("PTY shell created successfully");
323 Ok(shell)
324 }
325
326 fn initialize_prompt(&mut self, is_zsh: bool) -> Result<()> {
328 let prompt_statement = if is_zsh {
337 r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() { printf "◉ %s──➤ " "$PWD" }"#
341 } else {
342 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤ " "$PWD"'; PS1=''"#
343 };
344
345 self.write_command(prompt_statement)?;
346
347 std::thread::sleep(Duration::from_millis(100));
349 let _ = self.drain_output();
350
351 Ok(())
352 }
353
354 fn write_command(&mut self, command: &str) -> Result<()> {
356 let cmd_with_newline = format!("{command}\n");
358 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
359 self.writer.flush().context("Failed to flush PTY")?;
360 Ok(())
361 }
362
363 fn drain_output(&mut self) -> String {
365 let mut output = String::new();
366 let deadline = Instant::now() + Duration::from_millis(200);
367
368 while Instant::now() < deadline {
370 match self.output_rx.try_recv() {
371 Ok(chunk) => {
372 output.push_str(&chunk);
373
374 if output.len() > self.max_output_size {
376 self.output_truncated = true;
377 break;
378 }
379 }
380 Err(TryRecvError::Empty) => {
381 thread::sleep(Duration::from_millis(10));
383 }
384 Err(TryRecvError::Disconnected) => {
385 break;
387 }
388 }
389 }
390
391 output
392 }
393
394 pub fn clear_to_run(&mut self, max_wait_secs: f32) -> Result<bool> {
401 let (_, complete) = self.read_output(max_wait_secs.min(0.5))?;
404 if complete {
405 return Ok(true);
406 }
407
408 debug!("clear_to_run: prompt not seen, sending Ctrl+C");
410 self.send_interrupt()?;
411
412 let (_, drained) = self.read_output(max_wait_secs)?;
414 Ok(drained)
415 }
416
417 pub fn send_command(&mut self, command: &str) -> Result<()> {
419 debug!("PTY sending command: {}", command);
420
421 self.output_buffer.clear();
423 self.output_truncated = false;
424 self.last_command = command.to_string();
425 self.command_running = true;
426 self.last_returned_hash = None;
429
430 self.write_command(command)?;
432
433 Ok(())
434 }
435
436 fn ingest_into_ring(&mut self, chunk: &str) {
439 let combined = if self.line_ring_partial.is_empty() {
440 chunk.to_string()
441 } else {
442 let mut s = std::mem::take(&mut self.line_ring_partial);
443 s.push_str(chunk);
444 s
445 };
446
447 let mut last_nl_end: Option<usize> = None;
448 for (idx, ch) in combined.char_indices() {
449 if ch == '\n' {
450 let end = idx + ch.len_utf8();
451 let start = last_nl_end.unwrap_or(0);
452 let line = combined[start..idx].trim_end_matches('\r').to_string();
455 if self.line_ring.len() == RING_BUFFER_LINES {
456 self.line_ring.pop_front();
457 }
458 self.line_ring.push_back(line);
459 last_nl_end = Some(end);
460 }
461 }
462
463 if let Some(end) = last_nl_end {
464 self.line_ring_partial = combined[end..].to_string();
465 } else {
466 self.line_ring_partial = combined;
467 }
468 }
469
470 pub fn collect_scrollback(&self, lines: usize) -> String {
473 if lines == 0 {
474 return String::new();
475 }
476 let start = self.line_ring.len().saturating_sub(lines);
477 let mut out = String::new();
478 for line in self.line_ring.iter().skip(start) {
479 out.push_str(line);
480 out.push('\n');
481 }
482 if !self.line_ring_partial.is_empty() {
483 out.push_str(&self.line_ring_partial);
484 }
485 crate::state::terminal::render_terminal_output(&out).join("\n")
491 }
492
493 pub fn fingerprint(text: &str) -> u64 {
495 let mut hasher = DefaultHasher::new();
496 text.hash(&mut hasher);
497 hasher.finish()
498 }
499
500 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
505 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
506 let start = Instant::now();
507 let mut complete = false;
508 let mut no_data_count = 0;
509 let mut prompt_detected_at: Option<Instant> = None;
510
511 while start.elapsed() < timeout {
512 match self.output_rx.try_recv() {
513 Ok(chunk) => {
514 self.output_buffer.push_str(&chunk);
515 self.ingest_into_ring(&chunk);
516 no_data_count = 0;
517
518 if prompt_detected_at.is_none()
520 && (Self::check_prompt_complete(&chunk)
521 || Self::check_prompt_complete(&self.output_buffer))
522 {
523 prompt_detected_at = Some(Instant::now());
524 debug!("Prompt detected, draining remaining output...");
525 }
526
527 if self.output_buffer.len() > self.max_output_size {
529 self.output_truncated = true;
530 let truncate_msg = "\n(...output truncated...)\n";
531 let keep_size = self.max_output_size / 2;
532 let mut cut = self.output_buffer.len() - keep_size;
536 while cut < self.output_buffer.len()
537 && !self.output_buffer.is_char_boundary(cut)
538 {
539 cut += 1;
540 }
541 self.output_buffer =
542 format!("{truncate_msg}{}", &self.output_buffer[cut..]);
543 }
544 }
545 Err(TryRecvError::Empty) => {
546 thread::sleep(Duration::from_millis(10));
548 no_data_count += 1;
549
550 if let Some(detected_time) = prompt_detected_at {
552 if detected_time.elapsed() > Duration::from_millis(100) {
554 complete = true;
555 debug!("Command completed - prompt detected and drained");
556 break;
557 }
558 } else if no_data_count > 10 && Self::check_prompt_complete(&self.output_buffer)
559 {
560 prompt_detected_at = Some(Instant::now());
562 debug!("Prompt detected after wait, draining...");
563 }
564 }
565 Err(TryRecvError::Disconnected) => {
566 warn!("PTY reader disconnected");
568 complete = true;
569 break;
570 }
571 }
572 }
573
574 if complete || prompt_detected_at.is_some() {
575 self.command_running = false;
576 complete = true;
577 }
578
579 Ok((self.output_buffer.clone(), complete))
580 }
581
582 fn check_prompt_complete(text: &str) -> bool {
584 text.lines().rev().find(|line| !line.trim().is_empty()).is_some_and(|last| {
589 let clean = crate::state::terminal::strip_ansi_codes(last);
592 let clean = clean.trim_end();
593 clean.contains(WCGW_PROMPT_PATTERN) && clean.ends_with(WCGW_PROMPT_END)
594 })
595 }
596
597 pub fn send_interrupt(&mut self) -> Result<()> {
599 debug!("PTY sending Ctrl+C");
600 self.writer
601 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
603 self.writer.flush()?;
604 Ok(())
605 }
606
607 pub fn send_eof(&mut self) -> Result<()> {
609 debug!("PTY sending Ctrl+D");
610 self.writer
611 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
613 self.writer.flush()?;
614 Ok(())
615 }
616
617 pub fn send_suspend(&mut self) -> Result<()> {
619 debug!("PTY sending Ctrl+Z");
620 self.writer
621 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
623 self.writer.flush()?;
624 Ok(())
625 }
626
627 pub fn send_text(&mut self, text: &str) -> Result<()> {
629 debug!("PTY sending text: {:?}", text);
630 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
631 Ok(())
632 }
633
634 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
636 self.writer.write_all(bytes).context("Failed to send bytes")?;
637 self.writer.flush()?;
638 Ok(())
639 }
640
641 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
643 let bytes: &[u8] = match key {
644 "Enter" => b"\r",
645 "Tab" => b"\t",
646 "Backspace" => b"\x7F",
647 "Escape" => b"\x1B",
648 "Up" | "KeyUp" => b"\x1B[A",
649 "Down" | "KeyDown" => b"\x1B[B",
650 "Right" | "KeyRight" => b"\x1B[C",
651 "Left" | "KeyLeft" => b"\x1B[D",
652 "Home" => b"\x1B[H",
653 "End" => b"\x1B[F",
654 "PageUp" => b"\x1B[5~",
655 "PageDown" => b"\x1B[6~",
656 "Delete" => b"\x1B[3~",
657 "Insert" => b"\x1B[2~",
658 "CtrlC" | "Ctrl-C" => b"\x03",
659 "CtrlD" | "Ctrl-D" => b"\x04",
660 "CtrlZ" | "Ctrl-Z" => b"\x1A",
661 "CtrlL" | "Ctrl-L" => b"\x0C",
662 _ => return Err(anyhow!("Unknown special key: {key}")),
663 };
664
665 debug!("PTY sending special key: {} ({:?})", key, bytes);
666 self.send_bytes(bytes)?;
667 Ok(())
668 }
669
670 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
672 debug!("PTY resizing to {}x{}", cols, rows);
673
674 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
675
676 self.master.resize(new_size).context("Failed to resize PTY")?;
677
678 self.size = new_size;
679 Ok(())
680 }
681
682 pub fn get_size(&self) -> (u16, u16) {
684 (self.size.cols, self.size.rows)
685 }
686
687 pub fn is_alive(&mut self) -> bool {
689 self.child.try_wait().is_ok_and(|status| status.is_none())
690 }
691}
692
693pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
695
696pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
698 let shell = PtyShell::new(initial_dir, restricted_mode)?;
699 Ok(Arc::new(Mutex::new(Some(shell))))
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use tempfile::TempDir;
706
707 #[test]
708 fn prompt_detection_is_suffix_anchored() {
709 assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤ "));
711 assert!(PtyShell::check_prompt_complete("◉ /home/x──➤ \u{1b}[K"));
713 assert!(!PtyShell::check_prompt_complete("menu: ◉ start ──➤ stop\nstill running"));
715 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤ ls -la"));
717 assert!(!PtyShell::check_prompt_complete("just some output\n"));
719 }
720
721 #[test]
722 fn test_pty_shell_creation() -> Result<()> {
723 let temp_dir = TempDir::new()?;
724 let result = PtyShell::new(temp_dir.path(), false);
725 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
726 Ok(())
727 }
728
729 #[test]
730 fn test_pty_shell_echo() -> Result<()> {
731 let temp_dir = TempDir::new()?;
732 let mut shell = PtyShell::new(temp_dir.path(), false)?;
733
734 shell.send_command("echo 'hello pty'")?;
735 let (output, _complete) = shell.read_output(2.0)?;
736
737 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
738 Ok(())
739 }
740
741 #[test]
742 fn test_pty_shell_pwd() -> Result<()> {
743 let temp_dir = TempDir::new()?;
744 let mut shell = PtyShell::new(temp_dir.path(), false)?;
745
746 shell.send_command("pwd && echo 'pwd_done'")?;
749 let (output, _complete) = shell.read_output(2.0)?;
750
751 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
753 Ok(())
754 }
755
756 #[test]
757 fn test_pty_resize() -> Result<()> {
758 let temp_dir = TempDir::new()?;
759 let mut shell = PtyShell::new(temp_dir.path(), false)?;
760
761 let result = shell.resize(120, 40);
762 assert!(result.is_ok());
763
764 let (cols, rows) = shell.get_size();
765 assert_eq!(cols, 120);
766 assert_eq!(rows, 40);
767 Ok(())
768 }
769}