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>) {
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}")));
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}")));
66 }
67 }
68
69 let mut cmd = CommandBuilder::new("bash");
70 if restricted_mode {
71 cmd.arg("-r");
72 }
73 (cmd, None)
74}
75
76fn command_available(command: &str) -> bool {
77 Command::new("sh")
78 .args(["-c", &format!("command -v {command}")])
79 .output()
80 .is_ok_and(|output| output.status.success())
81}
82
83fn ensure_screenrc() {
86 let Some(home) = home::home_dir() else {
87 return;
88 };
89 let screenrc = home.join(".screenrc");
90 if screenrc.exists() {
91 return;
92 }
93 let _ = std::fs::write(
94 &screenrc,
95 "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
96 );
97}
98
99fn cleanup_orphaned_screens() {
105 let Ok(output) = Command::new("screen").arg("-ls").output() else {
106 return;
107 };
108 let listing = String::from_utf8_lossy(&output.stdout);
110 for line in listing.lines() {
111 let Some(session) = line.split_whitespace().next() else {
112 continue;
113 };
114 let Some((_, name)) = session.split_once('.') else {
116 continue;
117 };
118 if let Some(creator_pid) = winx_creator_pid(name) {
119 if !process_exists(creator_pid) {
120 let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
121 }
122 }
123 }
124}
125
126fn winx_creator_pid(name: &str) -> Option<u32> {
128 name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
129}
130
131fn process_exists(pid: u32) -> bool {
133 std::path::Path::new("/proc").join(pid.to_string()).exists()
134}
135
136fn timestamp_millis() -> u128 {
137 std::time::SystemTime::now()
138 .duration_since(std::time::UNIX_EPOCH)
139 .map_or(0, |duration| duration.as_millis())
140}
141
142pub struct PtyShell {
147 master: Box<dyn MasterPty + Send>,
149 child: Box<dyn Child + Send + Sync>,
151 writer: Box<dyn Write + Send>,
153 output_rx: mpsc::Receiver<String>,
155 size: PtySize,
157 pub last_command: String,
159 pub output_buffer: String,
161 pub command_running: bool,
163 max_output_size: usize,
165 pub output_truncated: bool,
167 pub line_ring: VecDeque<String>,
170 line_ring_partial: String,
173 pub last_returned_hash: Option<u64>,
176 pub attach_hint: Option<String>,
178}
179
180impl std::fmt::Debug for PtyShell {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("PtyShell")
183 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
184 .field("last_command", &self.last_command)
185 .field("command_running", &self.command_running)
186 .field("output_truncated", &self.output_truncated)
187 .field("output_buffer_len", &self.output_buffer.len())
188 .field("attach_hint", &self.attach_hint)
189 .finish_non_exhaustive()
190 }
191}
192
193impl PtyShell {
194 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
203 info!(
204 "Creating new PTY shell (restricted: {}) in {}",
205 restricted_mode,
206 initial_dir.display()
207 );
208
209 let pty_system = native_pty_system();
211
212 let size =
214 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
215
216 let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
218
219 let (mut cmd, attach_hint) = attachable_command(restricted_mode);
221
222 cmd.env("TERM", "xterm-256color");
224 cmd.env("COLORTERM", "truecolor");
225 cmd.env("PAGER", "cat");
226 cmd.env("GIT_PAGER", "cat");
227 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
228 cmd.env("ROWS", DEFAULT_ROWS.to_string());
229 cmd.env("PROMPT_COMMAND", r#"printf "◉ %s──➤ " "$PWD""#);
232 cmd.cwd(initial_dir);
233
234 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
236
237 let mut reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
239 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
240
241 let (output_tx, output_rx) = mpsc::channel::<String>();
243
244 thread::spawn(move || {
247 let mut buf = [0u8; 4096];
248 loop {
249 match reader.read(&mut buf) {
250 Ok(0) => {
251 break;
253 }
254 Ok(n) => {
255 let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
256 if output_tx.send(chunk).is_err() {
257 break;
259 }
260 }
261 Err(e) => {
262 debug!("PTY reader thread error: {}", e);
263 break;
264 }
265 }
266 }
267 debug!("PTY reader thread exiting");
268 });
269
270 let mut shell = Self {
272 master: pair.master,
273 child,
274 writer,
275 output_rx,
276 size,
277 last_command: String::new(),
278 output_buffer: String::new(),
279 command_running: false,
280 max_output_size: MAX_OUTPUT_SIZE,
281 output_truncated: false,
282 line_ring: VecDeque::with_capacity(RING_BUFFER_LINES),
283 line_ring_partial: String::new(),
284 last_returned_hash: None,
285 attach_hint,
286 };
287
288 shell.initialize_prompt()?;
290
291 debug!("PTY shell created successfully");
292 Ok(shell)
293 }
294
295 fn initialize_prompt(&mut self) -> Result<()> {
297 let prompt_statement =
300 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='printf "◉ %s──➤ " "$PWD"'"#;
301
302 self.write_command(prompt_statement)?;
303
304 std::thread::sleep(Duration::from_millis(100));
306 let _ = self.drain_output();
307
308 Ok(())
309 }
310
311 fn write_command(&mut self, command: &str) -> Result<()> {
313 let cmd_with_newline = format!("{command}\n");
315 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
316 self.writer.flush().context("Failed to flush PTY")?;
317 Ok(())
318 }
319
320 fn drain_output(&mut self) -> String {
322 let mut output = String::new();
323 let deadline = Instant::now() + Duration::from_millis(200);
324
325 while Instant::now() < deadline {
327 match self.output_rx.try_recv() {
328 Ok(chunk) => {
329 output.push_str(&chunk);
330
331 if output.len() > self.max_output_size {
333 self.output_truncated = true;
334 break;
335 }
336 }
337 Err(TryRecvError::Empty) => {
338 thread::sleep(Duration::from_millis(10));
340 }
341 Err(TryRecvError::Disconnected) => {
342 break;
344 }
345 }
346 }
347
348 output
349 }
350
351 pub fn clear_to_run(&mut self, max_wait_secs: f32) -> Result<bool> {
358 let (_, complete) = self.read_output(max_wait_secs.min(0.5))?;
361 if complete {
362 return Ok(true);
363 }
364
365 debug!("clear_to_run: prompt not seen, sending Ctrl+C");
367 self.send_interrupt()?;
368
369 let (_, drained) = self.read_output(max_wait_secs)?;
371 Ok(drained)
372 }
373
374 pub fn send_command(&mut self, command: &str) -> Result<()> {
376 debug!("PTY sending command: {}", command);
377
378 self.output_buffer.clear();
380 self.output_truncated = false;
381 self.last_command = command.to_string();
382 self.command_running = true;
383 self.last_returned_hash = None;
386
387 self.write_command(command)?;
389
390 Ok(())
391 }
392
393 fn ingest_into_ring(&mut self, chunk: &str) {
396 let combined = if self.line_ring_partial.is_empty() {
397 chunk.to_string()
398 } else {
399 let mut s = std::mem::take(&mut self.line_ring_partial);
400 s.push_str(chunk);
401 s
402 };
403
404 let mut last_nl_end: Option<usize> = None;
405 for (idx, ch) in combined.char_indices() {
406 if ch == '\n' {
407 let end = idx + ch.len_utf8();
408 let start = last_nl_end.unwrap_or(0);
409 let line = combined[start..idx].trim_end_matches('\r').to_string();
412 if self.line_ring.len() == RING_BUFFER_LINES {
413 self.line_ring.pop_front();
414 }
415 self.line_ring.push_back(line);
416 last_nl_end = Some(end);
417 }
418 }
419
420 if let Some(end) = last_nl_end {
421 self.line_ring_partial = combined[end..].to_string();
422 } else {
423 self.line_ring_partial = combined;
424 }
425 }
426
427 pub fn collect_scrollback(&self, lines: usize) -> String {
430 if lines == 0 {
431 return String::new();
432 }
433 let start = self.line_ring.len().saturating_sub(lines);
434 let mut out = String::new();
435 for line in self.line_ring.iter().skip(start) {
436 out.push_str(line);
437 out.push('\n');
438 }
439 if !self.line_ring_partial.is_empty() {
440 out.push_str(&self.line_ring_partial);
441 }
442 crate::state::terminal::render_terminal_output(&out).join("\n")
448 }
449
450 pub fn fingerprint(text: &str) -> u64 {
452 let mut hasher = DefaultHasher::new();
453 text.hash(&mut hasher);
454 hasher.finish()
455 }
456
457 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
462 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
463 let start = Instant::now();
464 let mut complete = false;
465 let mut no_data_count = 0;
466 let mut prompt_detected_at: Option<Instant> = None;
467
468 while start.elapsed() < timeout {
469 match self.output_rx.try_recv() {
470 Ok(chunk) => {
471 self.output_buffer.push_str(&chunk);
472 self.ingest_into_ring(&chunk);
473 no_data_count = 0;
474
475 if prompt_detected_at.is_none()
477 && (Self::check_prompt_complete(&chunk)
478 || Self::check_prompt_complete(&self.output_buffer))
479 {
480 prompt_detected_at = Some(Instant::now());
481 debug!("Prompt detected, draining remaining output...");
482 }
483
484 if self.output_buffer.len() > self.max_output_size {
486 self.output_truncated = true;
487 let truncate_msg = "\n(...output truncated...)\n";
488 let keep_size = self.max_output_size / 2;
489 self.output_buffer = format!(
490 "{}{}",
491 truncate_msg,
492 &self.output_buffer[self.output_buffer.len() - keep_size..]
493 );
494 }
495 }
496 Err(TryRecvError::Empty) => {
497 thread::sleep(Duration::from_millis(10));
499 no_data_count += 1;
500
501 if let Some(detected_time) = prompt_detected_at {
503 if detected_time.elapsed() > Duration::from_millis(100) {
505 complete = true;
506 debug!("Command completed - prompt detected and drained");
507 break;
508 }
509 } else if no_data_count > 10 && Self::check_prompt_complete(&self.output_buffer)
510 {
511 prompt_detected_at = Some(Instant::now());
513 debug!("Prompt detected after wait, draining...");
514 }
515 }
516 Err(TryRecvError::Disconnected) => {
517 warn!("PTY reader disconnected");
519 complete = true;
520 break;
521 }
522 }
523 }
524
525 if complete || prompt_detected_at.is_some() {
526 self.command_running = false;
527 complete = true;
528 }
529
530 Ok((self.output_buffer.clone(), complete))
531 }
532
533 fn check_prompt_complete(text: &str) -> bool {
535 text.contains(WCGW_PROMPT_PATTERN) && text.contains(WCGW_PROMPT_END)
537 }
538
539 pub fn send_interrupt(&mut self) -> Result<()> {
541 debug!("PTY sending Ctrl+C");
542 self.writer
543 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
545 self.writer.flush()?;
546 Ok(())
547 }
548
549 pub fn send_eof(&mut self) -> Result<()> {
551 debug!("PTY sending Ctrl+D");
552 self.writer
553 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
555 self.writer.flush()?;
556 Ok(())
557 }
558
559 pub fn send_suspend(&mut self) -> Result<()> {
561 debug!("PTY sending Ctrl+Z");
562 self.writer
563 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
565 self.writer.flush()?;
566 Ok(())
567 }
568
569 pub fn send_text(&mut self, text: &str) -> Result<()> {
571 debug!("PTY sending text: {:?}", text);
572 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
573 Ok(())
574 }
575
576 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
578 self.writer.write_all(bytes).context("Failed to send bytes")?;
579 self.writer.flush()?;
580 Ok(())
581 }
582
583 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
585 let bytes: &[u8] = match key {
586 "Enter" => b"\r",
587 "Tab" => b"\t",
588 "Backspace" => b"\x7F",
589 "Escape" => b"\x1B",
590 "Up" | "KeyUp" => b"\x1B[A",
591 "Down" | "KeyDown" => b"\x1B[B",
592 "Right" | "KeyRight" => b"\x1B[C",
593 "Left" | "KeyLeft" => b"\x1B[D",
594 "Home" => b"\x1B[H",
595 "End" => b"\x1B[F",
596 "PageUp" => b"\x1B[5~",
597 "PageDown" => b"\x1B[6~",
598 "Delete" => b"\x1B[3~",
599 "Insert" => b"\x1B[2~",
600 "CtrlC" | "Ctrl-C" => b"\x03",
601 "CtrlD" | "Ctrl-D" => b"\x04",
602 "CtrlZ" | "Ctrl-Z" => b"\x1A",
603 "CtrlL" | "Ctrl-L" => b"\x0C",
604 _ => return Err(anyhow!("Unknown special key: {key}")),
605 };
606
607 debug!("PTY sending special key: {} ({:?})", key, bytes);
608 self.send_bytes(bytes)?;
609 Ok(())
610 }
611
612 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
614 debug!("PTY resizing to {}x{}", cols, rows);
615
616 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
617
618 self.master.resize(new_size).context("Failed to resize PTY")?;
619
620 self.size = new_size;
621 Ok(())
622 }
623
624 pub fn get_size(&self) -> (u16, u16) {
626 (self.size.cols, self.size.rows)
627 }
628
629 pub fn is_alive(&mut self) -> bool {
631 self.child.try_wait().is_ok_and(|status| status.is_none())
632 }
633}
634
635pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
637
638pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
640 let shell = PtyShell::new(initial_dir, restricted_mode)?;
641 Ok(Arc::new(Mutex::new(Some(shell))))
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use tempfile::TempDir;
648
649 #[test]
650 fn test_pty_shell_creation() -> Result<()> {
651 let temp_dir = TempDir::new()?;
652 let result = PtyShell::new(temp_dir.path(), false);
653 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
654 Ok(())
655 }
656
657 #[test]
658 fn test_pty_shell_echo() -> Result<()> {
659 let temp_dir = TempDir::new()?;
660 let mut shell = PtyShell::new(temp_dir.path(), false)?;
661
662 shell.send_command("echo 'hello pty'")?;
663 let (output, _complete) = shell.read_output(2.0)?;
664
665 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
666 Ok(())
667 }
668
669 #[test]
670 fn test_pty_shell_pwd() -> Result<()> {
671 let temp_dir = TempDir::new()?;
672 let mut shell = PtyShell::new(temp_dir.path(), false)?;
673
674 shell.send_command("pwd && echo 'pwd_done'")?;
677 let (output, _complete) = shell.read_output(2.0)?;
678
679 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
681 Ok(())
682 }
683
684 #[test]
685 fn test_pty_resize() -> Result<()> {
686 let temp_dir = TempDir::new()?;
687 let mut shell = PtyShell::new(temp_dir.path(), false)?;
688
689 let result = shell.resize(120, 40);
690 assert!(result.is_ok());
691
692 let (cols, rows) = shell.get_size();
693 assert_eq!(cols, 120);
694 assert_eq!(rows, 40);
695 Ok(())
696 }
697}