From 3256167f48c3b33d929df6840559383e41028eb3 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 1 May 2026 17:11:08 +0000 Subject: [PATCH] test: add PTY integration test harness Add a pty-mock workspace crate (echo/alt-screen/paste-echo/buffer-fill/ sync-blocks subcommands) plus an in-process integration test suite that drives Proxy directly via a new_for_test constructor and #[cfg(test)] state accessors. Sets up the regression net needed before swapping the underlying VT emulator from vt100 to termwiz. Pulled from upstream draft PR #35; the alt-screen feed-order semantic change is intentionally deferred (its dedicated test is left #[ignore]). --- .github/workflows/ci.yml | 1 + Cargo.lock | 7 + Cargo.toml | 1 + crates/claude-chill/src/integration_tests.rs | 539 +++++++++++++++++++ crates/claude-chill/src/lib.rs | 2 + crates/claude-chill/src/proxy.rs | 103 +++- crates/pty-mock/Cargo.toml | 12 + crates/pty-mock/src/bin.rs | 146 +++++ flake.nix | 1 + 9 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 crates/claude-chill/src/integration_tests.rs create mode 100644 crates/pty-mock/Cargo.toml create mode 100644 crates/pty-mock/src/bin.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05fed9a..3cc2bfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - run: cargo build -p pty-mock - run: cargo test --all-targets test: diff --git a/Cargo.lock b/Cargo.lock index d2a7bc9..bd068f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,6 +750,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pty-mock" +version = "0.1.4" +dependencies = [ + "clap", +] + [[package]] name = "quote" version = "1.0.43" diff --git a/Cargo.toml b/Cargo.toml index 916c086..290bd39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/claude-chill", + "crates/pty-mock", ] [workspace.package] diff --git a/crates/claude-chill/src/integration_tests.rs b/crates/claude-chill/src/integration_tests.rs new file mode 100644 index 0000000..259c224 --- /dev/null +++ b/crates/claude-chill/src/integration_tests.rs @@ -0,0 +1,539 @@ +#[cfg(test)] +mod tests { + use crate::proxy::{Proxy, ProxyConfig}; + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; + use nix::pty::openpty; + use nix::sys::termios::{self, SetArg}; + use nix::unistd::read; + use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::process::CommandExt; + use std::process::{Child, Command}; + use std::time::Duration; + + fn pty_mock_binary() -> Option { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let bin = format!("{}/../../target/debug/pty-mock", manifest_dir); + if std::path::Path::new(&bin).exists() { + return Some(bin); + } + None + } + + fn spawn_command(cmd: &str, args: &[&str]) -> (OwnedFd, Child) { + let pty = openpty(None, None).expect("openpty failed"); + + let slave_fd = pty.slave.as_raw_fd(); + let mut termios_settings = termios::tcgetattr(&pty.slave).expect("tcgetattr failed"); + termios::cfmakeraw(&mut termios_settings); + termios::tcsetattr(&pty.slave, SetArg::TCSANOW, &termios_settings) + .expect("tcsetattr failed"); + + let child = unsafe { + Command::new(cmd) + .args(args) + .stdin(pty.slave.try_clone().expect("clone slave")) + .stdout(pty.slave.try_clone().expect("clone slave")) + .stderr(pty.slave.try_clone().expect("clone slave")) + .pre_exec(move || { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(slave_fd, libc::TIOCSCTTY as libc::c_ulong, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }) + .spawn() + .expect("failed to spawn command") + }; + + drop(pty.slave); + + // Set master to non-blocking + let flags = fcntl(&pty.master, FcntlArg::F_GETFL).expect("F_GETFL"); + let flags = OFlag::from_bits_truncate(flags); + fcntl(&pty.master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL"); + + (pty.master, child) + } + + fn spawn_mock(subcommand: &str) -> Option<(OwnedFd, Child)> { + let bin = pty_mock_binary()?; + Some(spawn_command(&bin, &[subcommand])) + } + + fn read_pty_output(master: &OwnedFd, timeout_ms: u16) -> Vec { + let mut result = Vec::new(); + let mut buf = [0u8; 65536]; + let mut poll_fds = [PollFd::new(master.as_fd(), PollFlags::POLLIN)]; + + loop { + match poll(&mut poll_fds, PollTimeout::from(timeout_ms)) { + Ok(0) => break, + Ok(_) => match read(master.as_fd(), &mut buf) { + Ok(0) => break, + Ok(n) => result.extend_from_slice(&buf[..n]), + Err(nix::errno::Errno::EAGAIN) => break, + Err(_) => break, + }, + Err(_) => break, + } + } + result + } + + fn make_proxy(master: OwnedFd, child: Child) -> Proxy { + let config = ProxyConfig::default(); + Proxy::new_for_test(master, child, config, 24, 80) + } + + fn dev_null() -> OwnedFd { + use std::fs::OpenOptions; + let f = OpenOptions::new() + .write(true) + .open("/dev/null") + .expect("open /dev/null"); + OwnedFd::from(f) + } + + fn dup_master(proxy: &Proxy) -> OwnedFd { + let raw = proxy.pty_master_fd_raw(); + unsafe { OwnedFd::from_raw_fd(libc::dup(raw)) } + } + + #[test] + fn test_echo_baseline() { + let Some((master, child)) = spawn_mock("echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + proxy + .process_input(b"hello world\r\n", &sink) + .expect("process_input"); + proxy.flush_drain_buffer(&sink).expect("flush drain"); + + std::thread::sleep(Duration::from_millis(50)); + + let master_dup = dup_master(&proxy); + let output = read_pty_output(&master_dup, 100); + assert!(!output.is_empty(), "expected output from echo child"); + proxy + .process_output(&output, &sink) + .expect("process_output"); + + let mut history = Vec::new(); + proxy.history().append_all(&mut history); + let history_str = String::from_utf8_lossy(&history); + assert!( + history_str.contains("hello world"), + "history should contain echoed text, got: {:?}", + history_str + ); + } + + #[test] + fn test_alt_screen_tracking() { + let Some((master, child)) = spawn_mock("alt-screen") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + std::thread::sleep(Duration::from_millis(50)); + + let master_dup = dup_master(&proxy); + let output = read_pty_output(&master_dup, 100); + if !output.is_empty() { + proxy + .process_output(&output, &sink) + .expect("process_output"); + } + + assert!( + proxy.is_in_alternate_screen(), + "proxy should be in alt screen" + ); + + proxy.process_input(b"x", &sink).expect("process_input"); + proxy.flush_drain_buffer(&sink).expect("flush drain"); + + std::thread::sleep(Duration::from_millis(50)); + + let master_dup = dup_master(&proxy); + let output = read_pty_output(&master_dup, 100); + if !output.is_empty() { + proxy + .process_output(&output, &sink) + .expect("process_output"); + } + + assert!( + !proxy.is_in_alternate_screen(), + "proxy should have exited alt screen" + ); + } + + #[test] + fn test_bracketed_paste_flow() { + let Some((master, child)) = spawn_mock("paste-echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + std::thread::sleep(Duration::from_millis(50)); + let master_dup = dup_master(&proxy); + let output = read_pty_output(&master_dup, 100); + if !output.is_empty() { + proxy + .process_output(&output, &sink) + .expect("process_output"); + } + + let mut paste = Vec::new(); + paste.extend_from_slice(b"\x1b[200~"); + paste.extend_from_slice(b"pasted content\r\n"); + paste.extend_from_slice(b"\x1b[201~"); + + proxy.process_input(&paste, &sink).expect("process_input"); + proxy.flush_drain_buffer(&sink).expect("flush drain"); + + assert!( + !proxy.is_in_bracketed_paste(), + "paste mode should be off after complete paste" + ); + } + + #[test] + fn test_split_paste_end_marker() { + let Some((master, child)) = spawn_mock("paste-echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + std::thread::sleep(Duration::from_millis(50)); + let master_dup = dup_master(&proxy); + let output = read_pty_output(&master_dup, 100); + if !output.is_empty() { + proxy + .process_output(&output, &sink) + .expect("process_output"); + } + + let mut chunk1 = Vec::new(); + chunk1.extend_from_slice(b"\x1b[200~"); + chunk1.extend_from_slice(b"split test\r\n"); + chunk1.extend_from_slice(b"\x1b[20"); // partial end marker + + proxy + .process_input(&chunk1, &sink) + .expect("process_input chunk1"); + proxy.flush_drain_buffer(&sink).expect("flush drain"); + + assert!( + proxy.is_in_bracketed_paste(), + "should still be in paste mode after partial end marker" + ); + + proxy + .process_input(b"1~", &sink) + .expect("process_input chunk2"); + proxy.flush_drain_buffer(&sink).expect("flush drain"); + + assert!( + !proxy.is_in_bracketed_paste(), + "paste mode should be off after completing split end marker" + ); + } + + #[test] + fn test_alt_screen_exit_restores_main_content() { + let Some((master, child)) = spawn_mock("echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + // Simulate child writing main screen content + let main_content = b"Claude Code v2.1.84\r\nOpus 4.6\r\n~/public/claude-chill\r\n"; + proxy + .process_output(main_content, &sink) + .expect("process_output main content"); + + let screen_before = proxy.vt_screen_text(); + assert!( + screen_before.contains("Claude Code v2.1.84"), + "main content should be on screen before alt screen, got: {:?}", + screen_before + ); + + // Child enters alt screen + proxy + .process_output(b"\x1b[?1049h", &sink) + .expect("process_output alt enter"); + assert!(proxy.is_in_alternate_screen()); + + // Child writes alt screen content (e.g. vim, less) + proxy + .process_output(b"alt screen editor content\r\nline 2\r\n", &sink) + .expect("process_output alt content"); + + // Child exits alt screen + proxy + .process_output(b"\x1b[?1049l", &sink) + .expect("process_output alt exit"); + assert!(!proxy.is_in_alternate_screen()); + + // The VT parser should have restored the main screen content + let screen_after = proxy.vt_screen_text(); + assert!( + screen_after.contains("Claude Code v2.1.84"), + "main content should be restored after alt screen exit, got: {:?}", + screen_after + ); + } + + // Skipped on this branch: depends on the alt-screen feed-order fix from + // upstream PR #35 (incremental VT feeding around alt enter/exit). We're + // pulling in only the test harness now; the semantic fix is a separate + // step. Re-enable by removing #[ignore] once that change lands. + #[test] + #[ignore] + fn test_alt_screen_exit_restores_after_clear_screen_sync_block() { + // Reproduces bug: sync block containing clear screen clears VT parser, + // but render_vt_screen never fires before alt-screen-enter arrives + // in the next chunk. VT parser saves blank screen. After alt exit, + // contents_formatted() paints blank over the real terminal which + // still had the original content (it never got the clear render). + let Some((master, child)) = spawn_mock("echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + // Initial content via sync block — this gets rendered + let mut initial_sync = Vec::new(); + initial_sync.extend_from_slice(b"\x1b[?2026h"); + initial_sync.extend_from_slice(b"\x1b[H"); + initial_sync.extend_from_slice(b"Claude Code v2.1.84\r\n"); + initial_sync.extend_from_slice(b"Opus 4.6\r\n"); + initial_sync.extend_from_slice(b"~/public/claude-chill\r\n"); + initial_sync.extend_from_slice(b"\x1b[?2026l"); + + proxy + .process_output(&initial_sync, &sink) + .expect("initial sync block"); + + let screen_before = proxy.vt_screen_text(); + assert!( + screen_before.contains("Claude Code v2.1.84"), + "header should be on screen initially" + ); + + // Sync block with clear screen (simulates ctrl-g response) — + // VT parser processes the clear but render never fires + let mut clear_sync = Vec::new(); + clear_sync.extend_from_slice(b"\x1b[?2026h"); + clear_sync.extend_from_slice(b"\x1b[2J"); // clear screen + clear_sync.extend_from_slice(b"\x1b[H"); + clear_sync.extend_from_slice(b"\x1b[?2026l"); + + proxy + .process_output(&clear_sync, &sink) + .expect("clear sync block"); + + // VT parser screen is now blank (clear screen was processed) + let screen_after_clear = proxy.vt_screen_text(); + assert!( + !screen_after_clear.contains("Claude Code"), + "VT screen should be blank after clear sync block" + ); + + // Alt screen enter in the NEXT chunk — no render_vt_screen fired + // between the clear sync and this. VT parser saves blank screen. + proxy + .process_output(b"\x1b[?1049h", &sink) + .expect("alt screen enter"); + assert!(proxy.is_in_alternate_screen()); + + // Editor content during alt screen + proxy + .process_output(b"editor content\r\n", &sink) + .expect("alt content"); + + // Alt screen exit — VT restores saved (blank) screen + proxy + .process_output(b"\x1b[?1049l", &sink) + .expect("alt screen exit"); + assert!(!proxy.is_in_alternate_screen()); + + // Clear screen wiped the main buffer before alt-screen saved it. + // Correct VT100 behavior: restored screen should be blank. + let screen_after_exit = proxy.vt_screen_text(); + assert!( + !screen_after_exit.contains("Claude Code v2.1.84"), + "header should NOT survive clear + alt screen round-trip, got: {:?}", + screen_after_exit + ); + } + + #[test] + fn test_sync_block_tracking() { + let Some((master, child)) = spawn_mock("echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + // Start a sync block without ending it + proxy + .process_output(b"\x1b[?2026h some content", &sink) + .expect("partial sync block"); + assert!( + proxy.is_in_sync_block(), + "should be in sync block after SYNC_START without SYNC_END" + ); + + // End the sync block + proxy + .process_output(b" more content \x1b[?2026l", &sink) + .expect("sync block end"); + assert!( + !proxy.is_in_sync_block(), + "should not be in sync block after SYNC_END" + ); + } + + #[test] + fn test_lookback_mode_toggle() { + let Some((master, child)) = spawn_mock("echo") else { + return; + }; + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + // Generate some output so lookback has content + proxy + .process_output(b"line 1\r\nline 2\r\nline 3\r\n", &sink) + .expect("output"); + + assert!(!proxy.is_in_lookback_mode()); + + // Send legacy lookback key (0x1E = Ctrl+^) + send_input(&mut proxy, &sink, &[0x1E]); + assert!( + proxy.is_in_lookback_mode(), + "should enter lookback mode after Ctrl+^" + ); + + // Send it again to exit + send_input(&mut proxy, &sink, &[0x1E]); + assert!( + !proxy.is_in_lookback_mode(), + "should exit lookback mode after second Ctrl+^" + ); + } + + fn pump_proxy(proxy: &mut Proxy, sink: &OwnedFd, timeout_ms: u16) { + let master_dup = dup_master(proxy); + let output = read_pty_output(&master_dup, timeout_ms); + if !output.is_empty() { + proxy.process_output(&output, sink).expect("process_output"); + } + } + + fn send_input(proxy: &mut Proxy, sink: &OwnedFd, data: &[u8]) { + proxy.process_input(data, sink).expect("process_input"); + proxy.flush_drain_buffer(sink).expect("flush drain"); + } + + fn wait_for_screen(proxy: &mut Proxy, sink: &OwnedFd, text: &str, timeout: Duration) -> bool { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + pump_proxy(proxy, sink, 100); + if proxy.vt_screen_text().contains(text) { + return true; + } + } + false + } + + #[test] + #[ignore] // requires claude CLI to be installed and authenticated + fn test_claude_alt_screen_restore() { + let (master, child) = spawn_command("claude", &[]); + let mut proxy = make_proxy(master, child); + let sink = dev_null(); + + // Wait for Claude Code to start up and show its UI + assert!( + wait_for_screen(&mut proxy, &sink, "Claude Code", Duration::from_secs(10)), + "Claude Code did not start, screen: {:?}", + proxy.vt_screen_text() + ); + + let screen_before = proxy.vt_screen_text(); + eprintln!("Screen before alt screen:\n{}", screen_before); + assert!( + screen_before.contains("▐▛███▜▌"), + "logo should be present before alt screen" + ); + + // Send Ctrl-G to open the editor (enters alt screen) + send_input(&mut proxy, &sink, &[0x07]); // Ctrl-G + + // Wait for alt screen to be entered + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + pump_proxy(&mut proxy, &sink, 100); + if proxy.is_in_alternate_screen() { + break; + } + } + assert!( + proxy.is_in_alternate_screen(), + "should have entered alt screen after Ctrl-G" + ); + + // Pump until editor has rendered + pump_proxy(&mut proxy, &sink, 500); + + // Send :wq to close the editor (exits alt screen) + send_input(&mut proxy, &sink, b"\x1b"); + pump_proxy(&mut proxy, &sink, 100); + send_input(&mut proxy, &sink, b":wq\r"); + + // Wait for alt screen to be exited + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + pump_proxy(&mut proxy, &sink, 100); + if !proxy.is_in_alternate_screen() { + break; + } + } + + // Drain remaining output after alt screen exit + pump_proxy(&mut proxy, &sink, 500); + + let vt_screen_after = proxy.vt_screen_text(); + + eprintln!("VT parser screen after alt exit:\n{}", vt_screen_after); + + // Claude Code bug: after editor exit, the logo (▐▛███▜▌) is not + // redrawn. This happens with or without claude-chill. + // Update this assertion if Claude Code fixes the redraw. + assert!( + !vt_screen_after.contains("▐▛███▜▌"), + "logo should NOT appear after alt screen exit (known Claude Code bug), got: {:?}", + vt_screen_after + ); + + // Send /exit to quit + send_input(&mut proxy, &sink, b"/exit\r"); + pump_proxy(&mut proxy, &sink, 1000); + } +} diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index 72f918b..1219fef 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -2,6 +2,8 @@ pub mod config; pub mod escape_filter; pub mod escape_sequences; pub mod history_filter; +#[cfg(test)] +mod integration_tests; pub mod key_parser; pub mod line_buffer; pub mod proxy; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index f294dce..071c15b 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -322,6 +322,105 @@ impl Proxy { }) } + #[cfg(test)] + pub(crate) fn new_for_test( + pty_master: OwnedFd, + child: Child, + config: ProxyConfig, + rows: u16, + cols: u16, + ) -> Self { + let vt_parser = vt100::Parser::new(rows, cols, 0); + let mut history = LineBuffer::new(config.max_history_lines); + history.push_bytes(CLEAR_SCREEN); + history.push_bytes(CURSOR_HOME); + let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms); + + Self { + history, + history_filter: HistoryFilter::new(), + config, + pty_master, + child, + original_termios: None, + vt_parser, + vt_prev_screen: None, + last_output_time: None, + last_render_time: None, + last_stdin_time: None, + last_auto_lookback_time: None, + auto_lookback_timeout, + sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY), + in_sync_block: false, + in_lookback_mode: false, + in_alternate_screen: false, + in_bracketed_paste: false, + kitty_mode_supported: false, + kitty_mode_stack: 0, + kitty_output_parser: TermwizParser::new(), + vt_render_pending: false, + lookback_cache: Vec::new(), + lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY), + output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY), + sync_start_finder: memmem::Finder::new(SYNC_START), + sync_end_finder: memmem::Finder::new(SYNC_END), + clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN), + cursor_home_finder: memmem::Finder::new(CURSOR_HOME), + alt_screen_enter_finder: memmem::Finder::new(ALT_SCREEN_ENTER), + alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT), + alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY), + alt_screen_exit_legacy_finder: memmem::Finder::new(ALT_SCREEN_EXIT_LEGACY), + paste_start_finder: memmem::Finder::new(BRACKETED_PASTE_START), + paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END), + pty_drain_buffer: Vec::new(), + paste_remainder: Vec::new(), + } + } + + #[cfg(test)] + pub(crate) fn history(&self) -> &LineBuffer { + &self.history + } + + #[cfg(test)] + pub(crate) fn is_in_alternate_screen(&self) -> bool { + self.in_alternate_screen + } + + #[cfg(test)] + pub(crate) fn is_in_bracketed_paste(&self) -> bool { + self.in_bracketed_paste + } + + #[cfg(test)] + pub(crate) fn is_in_sync_block(&self) -> bool { + self.in_sync_block + } + + #[cfg(test)] + pub(crate) fn is_in_lookback_mode(&self) -> bool { + self.in_lookback_mode + } + + #[cfg(test)] + pub(crate) fn vt_screen_text(&self) -> String { + self.vt_parser.screen().contents() + } + + #[cfg(test)] + pub(crate) fn pty_master_fd_raw(&self) -> i32 { + self.pty_master.as_raw_fd() + } + + #[cfg(test)] + pub(crate) fn flush_drain_buffer(&mut self, stdout_fd: &F) -> Result<()> { + if !self.pty_drain_buffer.is_empty() { + let drained = std::mem::take(&mut self.pty_drain_buffer); + self.process_output(&drained, stdout_fd)?; + } + Ok(()) + } + pub fn run(&mut self) -> Result { let stdin_fd = io::stdin(); let stdout_fd = io::stdout(); @@ -406,7 +505,7 @@ impl Proxy { self.wait_child() } - fn process_output(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { + pub(crate) fn process_output(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { self.process_output_inner(data, stdout_fd, true) } @@ -842,7 +941,7 @@ impl Proxy { Ok(()) } - fn process_input(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { + pub(crate) fn process_input(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { self.last_stdin_time = Some(Instant::now()); debug!( diff --git a/crates/pty-mock/Cargo.toml b/crates/pty-mock/Cargo.toml new file mode 100644 index 0000000..838f839 --- /dev/null +++ b/crates/pty-mock/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pty-mock" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[[bin]] +name = "pty-mock" +path = "src/bin.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } diff --git a/crates/pty-mock/src/bin.rs b/crates/pty-mock/src/bin.rs new file mode 100644 index 0000000..f3c3b30 --- /dev/null +++ b/crates/pty-mock/src/bin.rs @@ -0,0 +1,146 @@ +use clap::{Parser, Subcommand}; +use std::io::{self, BufRead, Read, Write}; + +#[derive(Parser)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Echo, + AltScreen, + PasteEcho, + BufferFill, + SyncBlocks, +} + +fn cmd_echo() { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if writeln!(stdout, "{}", line).is_err() { + break; + } + if stdout.flush().is_err() { + break; + } + } +} + +fn cmd_alt_screen() { + let mut stdout = io::stdout(); + let mut stdin = io::stdin(); + + // Enter alt screen + let _ = stdout.write_all(b"\x1b[?1049h"); + let _ = stdout.flush(); + + // Write some content while in alt screen + let _ = stdout.write_all(b"alt screen content\r\n"); + let _ = stdout.flush(); + + // Wait for any byte on stdin as trigger to exit + let mut trigger = [0u8; 1]; + let _ = stdin.read_exact(&mut trigger); + + // Exit alt screen + let _ = stdout.write_all(b"\x1b[?1049l"); + let _ = stdout.flush(); + + // Write content after alt screen exit + let _ = stdout.write_all(b"back to normal\r\n"); + let _ = stdout.flush(); +} + +fn cmd_paste_echo() { + let mut stdout = io::stdout(); + let stdin = io::stdin(); + + // Enable bracketed paste mode + let _ = stdout.write_all(b"\x1b[?2004h"); + let _ = stdout.flush(); + + // Read lines, prefix paste content + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if writeln!(stdout, "pasted: {}", line).is_err() { + break; + } + if stdout.flush().is_err() { + break; + } + } +} + +fn cmd_buffer_fill() { + let mut stdout = io::stdout(); + let stdin = io::stdin(); + let mut stdin_lock = stdin.lock(); + + // Write large output to fill the PTY buffer + let chunk = "X".repeat(1024) + "\r\n"; + for _ in 0..256 { + if stdout.write_all(chunk.as_bytes()).is_err() { + break; + } + } + let _ = stdout.flush(); + + // Now read stdin and echo it back + let mut buf = [0u8; 4096]; + loop { + match stdin_lock.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if stdout.write_all(&buf[..n]).is_err() { + break; + } + if stdout.flush().is_err() { + break; + } + } + Err(_) => break, + } + } +} + +fn cmd_sync_blocks() { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + // Wrap response in sync update block + let _ = stdout.write_all(b"\x1b[?2026h"); + if writeln!(stdout, "sync: {}", line).is_err() { + break; + } + let _ = stdout.write_all(b"\x1b[?2026l"); + if stdout.flush().is_err() { + break; + } + } +} + +fn main() { + let cli = Cli::parse(); + match cli.command { + Commands::Echo => cmd_echo(), + Commands::AltScreen => cmd_alt_screen(), + Commands::PasteEcho => cmd_paste_echo(), + Commands::BufferFill => cmd_buffer_fill(), + Commands::SyncBlocks => cmd_sync_blocks(), + } +} diff --git a/flake.nix b/flake.nix index 89ffe1c..fe4f428 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,7 @@ cargoLock = { lockFile = ./Cargo.lock; }; + cargoBuildFlags = [ "-p" "claude-chill" ]; }; in