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 MAX_PARTIAL_LINE_BYTES: usize = 64 * 1024;
41
42const WCGW_PROMPT_PATTERN: &str = "◉";
44const WCGW_PROMPT_END: &str = "──➤";
45
46fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, bool) {
47 let requested = std::env::var("WINX_ATTACH_TERMINAL")
48 .or_else(|_| std::env::var("WINX_USE_SCREEN"))
49 .unwrap_or_default();
50 if !requested.is_empty() && requested != "0" && requested != "false" {
51 let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
52 if requested == "tmux" && command_available("tmux") {
53 let mut cmd = CommandBuilder::new("tmux");
54 cmd.args(["new-session", "-A", "-s", &session, "bash"]);
55 if restricted_mode {
56 cmd.arg("-r");
57 }
58 return (cmd, Some(format!("tmux attach -t {session}")), false);
59 }
60 if command_available("screen") {
61 ensure_screenrc();
64 cleanup_orphaned_screens();
65 let mut cmd = CommandBuilder::new("screen");
66 cmd.args(["-q", "-S", &session, "bash"]);
67 if restricted_mode {
68 cmd.arg("-r");
69 }
70 return (cmd, Some(format!("screen -x {session}")), false);
71 }
72 }
73
74 let shell = preferred_shell(restricted_mode);
75 let is_zsh = shell == "zsh";
76 let mut cmd = CommandBuilder::new(&shell);
77 if restricted_mode && !is_zsh {
79 cmd.arg("-r");
80 }
81 (cmd, None, is_zsh)
82}
83
84fn preferred_shell(restricted_mode: bool) -> String {
88 if !restricted_mode {
89 if let Ok(requested) = std::env::var("WINX_SHELL") {
90 if requested == "zsh" && command_available("zsh") {
91 return "zsh".to_string();
92 }
93 }
94 }
95 "bash".to_string()
96}
97
98fn command_available(command: &str) -> bool {
99 Command::new("sh")
100 .args(["-c", &format!("command -v {command}")])
101 .output()
102 .is_ok_and(|output| output.status.success())
103}
104
105fn ensure_screenrc() {
108 let Some(home) = home::home_dir() else {
109 return;
110 };
111 let screenrc = home.join(".screenrc");
112 if screenrc.exists() {
113 return;
114 }
115 let _ = std::fs::write(
116 &screenrc,
117 "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
118 );
119}
120
121fn cleanup_orphaned_screens() {
127 let Ok(output) = Command::new("screen").arg("-ls").output() else {
128 return;
129 };
130 let listing = String::from_utf8_lossy(&output.stdout);
132 for line in listing.lines() {
133 let Some(session) = line.split_whitespace().next() else {
134 continue;
135 };
136 let Some((_, name)) = session.split_once('.') else {
138 continue;
139 };
140 if let Some(creator_pid) = winx_creator_pid(name) {
141 if !process_exists(creator_pid) {
142 let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
143 }
144 }
145 }
146}
147
148fn winx_creator_pid(name: &str) -> Option<u32> {
150 name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
151}
152
153fn process_exists(pid: u32) -> bool {
155 std::path::Path::new("/proc").join(pid.to_string()).exists()
156}
157
158fn timestamp_millis() -> u128 {
159 std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .map_or(0, |duration| duration.as_millis())
162}
163
164pub struct PtyShell {
169 master: Box<dyn MasterPty + Send>,
171 child: Box<dyn Child + Send + Sync>,
173 writer: Box<dyn Write + Send>,
175 output_rx: mpsc::Receiver<String>,
177 size: PtySize,
179 pub last_command: String,
181 pub output_buffer: String,
183 pub command_running: bool,
185 max_output_size: usize,
187 pub output_truncated: bool,
189 pub line_ring: VecDeque<String>,
192 line_ring_partial: String,
195 pub last_returned_hash: Option<u64>,
198 pub attach_hint: Option<String>,
200 prompt_end_marker: String,
206}
207
208impl std::fmt::Debug for PtyShell {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 f.debug_struct("PtyShell")
211 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
212 .field("last_command", &self.last_command)
213 .field("command_running", &self.command_running)
214 .field("output_truncated", &self.output_truncated)
215 .field("output_buffer_len", &self.output_buffer.len())
216 .field("attach_hint", &self.attach_hint)
217 .finish_non_exhaustive()
218 }
219}
220
221impl Drop for PtyShell {
222 fn drop(&mut self) {
230 let _ = self.child.kill();
231 let _ = self.child.wait();
232 }
233}
234
235impl PtyShell {
236 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
245 info!(
246 "Creating new PTY shell (restricted: {}) in {}",
247 restricted_mode,
248 initial_dir.display()
249 );
250
251 let pty_system = native_pty_system();
253
254 let size =
256 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
257
258 let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
260
261 let (mut cmd, attach_hint, is_zsh) = attachable_command(restricted_mode);
263
264 cmd.env("TERM", "xterm-256color");
266 cmd.env("COLORTERM", "truecolor");
267 cmd.env("PAGER", "cat");
268 cmd.env("GIT_PAGER", "cat");
269 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
270 cmd.env("ROWS", DEFAULT_ROWS.to_string());
271 let nonce = format!("{:016x}", rand::random::<u64>());
274 cmd.env("PROMPT_COMMAND", format!(r#"printf "◉ %s──➤{nonce} " "$PWD""#));
277 cmd.cwd(initial_dir);
278
279 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
281
282 let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
284 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
285
286 let (output_tx, output_rx) = mpsc::channel::<String>();
288
289 thread::spawn(move || {
292 let mut buf = [0u8; 4096];
293 loop {
294 match reader.read(&mut buf) {
295 Ok(0) => {
296 break;
298 }
299 Ok(n) => {
300 let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
301 if output_tx.send(chunk).is_err() {
302 break;
304 }
305 }
306 Err(e) => {
307 debug!("PTY reader thread error: {}", e);
308 break;
309 }
310 }
311 }
312 debug!("PTY reader thread exiting");
313 });
314
315 let mut shell = Self {
317 master: pair.master,
318 child,
319 writer,
320 output_rx,
321 size,
322 last_command: String::new(),
323 output_buffer: String::new(),
324 command_running: false,
325 max_output_size: MAX_OUTPUT_SIZE,
326 output_truncated: false,
327 line_ring: VecDeque::with_capacity(RING_BUFFER_LINES),
328 line_ring_partial: String::new(),
329 last_returned_hash: None,
330 attach_hint,
331 prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
332 };
333
334 shell.initialize_prompt(is_zsh, &nonce)?;
336
337 debug!("PTY shell created successfully");
338 Ok(shell)
339 }
340
341 fn initialize_prompt(&mut self, is_zsh: bool, nonce: &str) -> Result<()> {
343 let prompt_statement = if is_zsh {
352 format!(
356 r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ printf "◉ %s──➤{nonce} " "$PWD" }}"#
357 )
358 } else {
359 format!(
360 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤{nonce} " "$PWD"'; PS1=''"#
361 )
362 };
363
364 self.write_command(&prompt_statement)?;
365
366 std::thread::sleep(Duration::from_millis(100));
368 let _ = self.drain_output();
369
370 Ok(())
371 }
372
373 fn write_command(&mut self, command: &str) -> Result<()> {
375 let cmd_with_newline = format!("{command}\r");
381 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
382 self.writer.flush().context("Failed to flush PTY")?;
383 Ok(())
384 }
385
386 fn drain_output(&mut self) -> String {
388 let mut output = String::new();
389 let deadline = Instant::now() + Duration::from_millis(200);
390
391 while Instant::now() < deadline {
393 match self.output_rx.try_recv() {
394 Ok(chunk) => {
395 output.push_str(&chunk);
396
397 if output.len() > self.max_output_size {
399 self.output_truncated = true;
400 break;
401 }
402 }
403 Err(TryRecvError::Empty) => {
404 thread::sleep(Duration::from_millis(10));
406 }
407 Err(TryRecvError::Disconnected) => {
408 break;
410 }
411 }
412 }
413
414 output
415 }
416
417 pub fn clear_to_run(&mut self, max_wait_secs: f32) -> Result<bool> {
424 let (_, complete) = self.read_output(max_wait_secs.min(0.5))?;
427 if complete {
428 return Ok(true);
429 }
430
431 debug!("clear_to_run: prompt not seen, sending Ctrl+C");
433 self.send_interrupt()?;
434
435 let (_, drained) = self.read_output(max_wait_secs)?;
437 Ok(drained)
438 }
439
440 pub fn send_command(&mut self, command: &str) -> Result<()> {
442 debug!("PTY sending command: {}", command);
443
444 self.output_buffer.clear();
446 self.output_truncated = false;
447 self.last_command = command.to_string();
448 self.command_running = true;
449 self.last_returned_hash = None;
452
453 self.write_command(command)?;
455
456 Ok(())
457 }
458
459 fn ingest_into_ring(&mut self, chunk: &str) {
462 let combined = if self.line_ring_partial.is_empty() {
463 chunk.to_string()
464 } else {
465 let mut s = std::mem::take(&mut self.line_ring_partial);
466 s.push_str(chunk);
467 s
468 };
469
470 let mut last_nl_end: Option<usize> = None;
471 for (idx, ch) in combined.char_indices() {
472 if ch == '\n' {
473 let end = idx + ch.len_utf8();
474 let start = last_nl_end.unwrap_or(0);
475 let line = combined[start..idx].trim_end_matches('\r').to_string();
478 if self.line_ring.len() == RING_BUFFER_LINES {
479 self.line_ring.pop_front();
480 }
481 self.line_ring.push_back(line);
482 last_nl_end = Some(end);
483 }
484 }
485
486 if let Some(end) = last_nl_end {
487 self.line_ring_partial = combined[end..].to_string();
488 } else {
489 self.line_ring_partial = combined;
490 }
491
492 if self.line_ring_partial.len() > MAX_PARTIAL_LINE_BYTES {
497 let cut = self.line_ring_partial.len() - MAX_PARTIAL_LINE_BYTES;
498 let cut = crate::utils::floor_char_boundary(&self.line_ring_partial, cut);
499 self.line_ring_partial.drain(..cut);
500 }
501 }
502
503 pub fn collect_scrollback(&self, lines: usize) -> String {
506 if lines == 0 {
507 return String::new();
508 }
509 let start = self.line_ring.len().saturating_sub(lines);
510 let mut out = String::new();
511 for line in self.line_ring.iter().skip(start) {
512 out.push_str(line);
513 out.push('\n');
514 }
515 if !self.line_ring_partial.is_empty() {
516 out.push_str(&self.line_ring_partial);
517 }
518 crate::state::terminal::render_terminal_output(&out).join("\n")
524 }
525
526 pub fn fingerprint(text: &str) -> u64 {
528 let mut hasher = DefaultHasher::new();
529 text.hash(&mut hasher);
530 hasher.finish()
531 }
532
533 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
538 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
539 let start = Instant::now();
540 let mut complete = false;
541 let mut no_data_count = 0;
542 let mut prompt_detected_at: Option<Instant> = None;
543
544 while start.elapsed() < timeout {
545 match self.output_rx.try_recv() {
546 Ok(chunk) => {
547 self.output_buffer.push_str(&chunk);
548 self.ingest_into_ring(&chunk);
549 no_data_count = 0;
550
551 if prompt_detected_at.is_none()
553 && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
554 || Self::check_prompt_complete(
555 &self.output_buffer,
556 &self.prompt_end_marker,
557 ))
558 {
559 prompt_detected_at = Some(Instant::now());
560 debug!("Prompt detected, draining remaining output...");
561 }
562
563 if self.output_buffer.len() > self.max_output_size {
565 self.output_truncated = true;
566 let truncate_msg = "\n(...output truncated...)\n";
567 let keep_size = self.max_output_size / 2;
568 let mut cut = self.output_buffer.len() - keep_size;
572 while cut < self.output_buffer.len()
573 && !self.output_buffer.is_char_boundary(cut)
574 {
575 cut += 1;
576 }
577 self.output_buffer =
578 format!("{truncate_msg}{}", &self.output_buffer[cut..]);
579 }
580 }
581 Err(TryRecvError::Empty) => {
582 thread::sleep(Duration::from_millis(10));
584 no_data_count += 1;
585
586 if let Some(detected_time) = prompt_detected_at {
588 if detected_time.elapsed() > Duration::from_millis(100) {
590 complete = true;
591 debug!("Command completed - prompt detected and drained");
592 break;
593 }
594 } else if no_data_count > 10
595 && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
596 {
597 prompt_detected_at = Some(Instant::now());
599 debug!("Prompt detected after wait, draining...");
600 }
601 }
602 Err(TryRecvError::Disconnected) => {
603 warn!("PTY reader disconnected");
605 complete = true;
606 break;
607 }
608 }
609 }
610
611 if complete || prompt_detected_at.is_some() {
612 self.command_running = false;
613 complete = true;
614 }
615
616 Ok((self.output_buffer.clone(), complete))
617 }
618
619 fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
627 text.lines().rev().find(|line| !line.trim().is_empty()).is_some_and(|last| {
628 let clean = crate::state::terminal::strip_ansi_codes(last);
631 let clean = clean.trim_end();
632 clean.contains(WCGW_PROMPT_PATTERN) && clean.ends_with(prompt_end)
633 })
634 }
635
636 pub fn send_interrupt(&mut self) -> Result<()> {
638 debug!("PTY sending Ctrl+C");
639 self.writer
640 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
642 self.writer.flush()?;
643 Ok(())
644 }
645
646 pub fn send_eof(&mut self) -> Result<()> {
648 debug!("PTY sending Ctrl+D");
649 self.writer
650 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
652 self.writer.flush()?;
653 Ok(())
654 }
655
656 pub fn send_suspend(&mut self) -> Result<()> {
658 debug!("PTY sending Ctrl+Z");
659 self.writer
660 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
662 self.writer.flush()?;
663 Ok(())
664 }
665
666 pub fn send_text(&mut self, text: &str) -> Result<()> {
668 debug!("PTY sending text: {:?}", text);
669 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
670 Ok(())
671 }
672
673 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
675 self.writer.write_all(bytes).context("Failed to send bytes")?;
676 self.writer.flush()?;
677 Ok(())
678 }
679
680 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
682 let bytes: &[u8] = match key {
683 "Enter" => b"\r",
684 "Tab" => b"\t",
685 "Backspace" => b"\x7F",
686 "Escape" => b"\x1B",
687 "Up" | "KeyUp" => b"\x1B[A",
688 "Down" | "KeyDown" => b"\x1B[B",
689 "Right" | "KeyRight" => b"\x1B[C",
690 "Left" | "KeyLeft" => b"\x1B[D",
691 "Home" => b"\x1B[H",
692 "End" => b"\x1B[F",
693 "PageUp" => b"\x1B[5~",
694 "PageDown" => b"\x1B[6~",
695 "Delete" => b"\x1B[3~",
696 "Insert" => b"\x1B[2~",
697 "CtrlC" | "Ctrl-C" => b"\x03",
698 "CtrlD" | "Ctrl-D" => b"\x04",
699 "CtrlZ" | "Ctrl-Z" => b"\x1A",
700 "CtrlL" | "Ctrl-L" => b"\x0C",
701 _ => return Err(anyhow!("Unknown special key: {key}")),
702 };
703
704 debug!("PTY sending special key: {} ({:?})", key, bytes);
705 self.send_bytes(bytes)?;
706 Ok(())
707 }
708
709 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
711 debug!("PTY resizing to {}x{}", cols, rows);
712
713 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
714
715 self.master.resize(new_size).context("Failed to resize PTY")?;
716
717 self.size = new_size;
718 Ok(())
719 }
720
721 pub fn get_size(&self) -> (u16, u16) {
723 (self.size.cols, self.size.rows)
724 }
725
726 pub fn is_alive(&mut self) -> bool {
728 self.child.try_wait().is_ok_and(|status| status.is_none())
729 }
730}
731
732pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
734
735pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
737 let shell = PtyShell::new(initial_dir, restricted_mode)?;
738 Ok(Arc::new(Mutex::new(Some(shell))))
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use tempfile::TempDir;
745
746 #[test]
747 fn prompt_detection_is_suffix_anchored() {
748 let end = "──➤deadbeefcafe0001";
750 assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001 ", end));
752 assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 \u{1b}[K", end));
754 assert!(!PtyShell::check_prompt_complete(
756 "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
757 end
758 ));
759 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ls -la", end));
761 assert!(!PtyShell::check_prompt_complete("just some output\n", end));
763 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
766 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce ", end));
767 }
768
769 #[test]
770 fn test_pty_shell_creation() -> Result<()> {
771 let temp_dir = TempDir::new()?;
772 let result = PtyShell::new(temp_dir.path(), false);
773 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
774 Ok(())
775 }
776
777 #[test]
778 fn test_pty_shell_echo() -> Result<()> {
779 let temp_dir = TempDir::new()?;
780 let mut shell = PtyShell::new(temp_dir.path(), false)?;
781
782 shell.send_command("echo 'hello pty'")?;
783 let (output, _complete) = shell.read_output(2.0)?;
784
785 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
786 Ok(())
787 }
788
789 #[test]
790 fn test_pty_shell_pwd() -> Result<()> {
791 let temp_dir = TempDir::new()?;
792 let mut shell = PtyShell::new(temp_dir.path(), false)?;
793
794 shell.send_command("pwd && echo 'pwd_done'")?;
797 let (output, _complete) = shell.read_output(2.0)?;
798
799 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
801 Ok(())
802 }
803
804 #[test]
805 fn test_pty_resize() -> Result<()> {
806 let temp_dir = TempDir::new()?;
807 let mut shell = PtyShell::new(temp_dir.path(), false)?;
808
809 let result = shell.resize(120, 40);
810 assert!(result.is_ok());
811
812 let (cols, rows) = shell.get_size();
813 assert_eq!(cols, 120);
814 assert_eq!(rows, 40);
815 Ok(())
816 }
817}