From 3492b2b1c58e0e234bcb5748ba851cd8b854bc95 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 26 May 2026 17:38:11 +0000 Subject: [PATCH] Add passthrough-mode tests; stop wiping screen on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxy.rs: expose set_passthrough_mode/vt_render_pending test hooks, and on Drop only reset attribute state (fg/bg/hyperlink) instead of also clearing the viewport — so the child's final output stays on screen like a normal CLI once the palimpsest bug is fixed. integration_tests.rs: tests asserting passthrough mode forwards child PTY bytes verbatim (no termwiz re-encoding) and skips the deferred render path. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/claude-chill/src/integration_tests.rs | 130 +++++++++++++++++++ crates/claude-chill/src/proxy.rs | 26 ++-- 2 files changed, 145 insertions(+), 11 deletions(-) diff --git a/crates/claude-chill/src/integration_tests.rs b/crates/claude-chill/src/integration_tests.rs index 8e7c522..d7ed924 100644 --- a/crates/claude-chill/src/integration_tests.rs +++ b/crates/claude-chill/src/integration_tests.rs @@ -518,6 +518,136 @@ mod tests { ); } + /// Helper: build a proxy with passthrough_mode enabled and a captured + /// stdout pipe. Returns the proxy, the read end of the pipe (rx), and + /// the write end (tx) — pass `tx` as the stdout_fd in `process_*` + /// calls and read `rx` to inspect what the proxy emitted downstream. + fn passthrough_proxy_with_capture() -> (Proxy, OwnedFd, OwnedFd) { + use nix::unistd::pipe; + let (master, child) = spawn_mock("echo"); + let mut proxy = make_proxy(master, child); + proxy.set_passthrough_mode(true); + let (rx, tx) = pipe().expect("pipe"); + let flags = fcntl(&rx, FcntlArg::F_GETFL).expect("F_GETFL"); + let flags = OFlag::from_bits_truncate(flags); + fcntl(&rx, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL"); + (proxy, rx, tx) + } + + fn drain_pipe(rx: &OwnedFd) -> Vec { + let mut out = Vec::new(); + let mut buf = [0u8; 65536]; + loop { + match nix::unistd::read(rx.as_fd(), &mut buf) { + Ok(0) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + out + } + + /// Core invariant of passthrough mode: bytes from the child PTY land + /// on the real terminal byte-for-byte, with no termwiz re-encoding. + /// This is what makes claude-chill correct under tmux — tmux sees the + /// exact byte stream the child emitted, not a diff'd repaint that can + /// drift from tmux's grid. + #[test] + fn test_passthrough_forwards_bytes_unchanged() { + let (mut proxy, rx, tx) = passthrough_proxy_with_capture(); + + let input = b"\x1b[31mhello\x1b[0m \x1b]8;;https://x.example\x1b\\link\x1b]8;;\x1b\\\r\n"; + proxy.process_output(input, &tx).expect("process_output"); + + let captured = drain_pipe(&rx); + assert_eq!( + captured, + input, + "passthrough must forward bytes verbatim; got {:?} vs {:?}", + String::from_utf8_lossy(&captured), + String::from_utf8_lossy(input) + ); + } + + /// Passthrough mode skips the deferred-render path entirely so the + /// 33ms render loop never fires for steady-state child output. + #[test] + fn test_passthrough_skips_render_pending() { + let (mut proxy, _rx, tx) = passthrough_proxy_with_capture(); + + proxy + .process_output(b"some output\r\n", &tx) + .expect("process_output"); + + assert!( + !proxy.vt_render_pending(), + "passthrough mode must not arm the deferred VT render" + ); + } + + /// State tracking still has to work in passthrough mode — lookback + /// (which dumps history then renders a one-shot full repaint) and + /// alt-screen detection both depend on wezterm-term's grid being + /// kept up to date. + #[test] + fn test_passthrough_still_feeds_screen_engine() { + let (mut proxy, _rx, tx) = passthrough_proxy_with_capture(); + + proxy + .process_output(b"hello passthrough\r\n", &tx) + .expect("process_output"); + + let screen = proxy.vt_screen_text(); + assert!( + screen.contains("hello passthrough"), + "wezterm-term grid must reflect child output even in \ + passthrough mode, got: {:?}", + screen + ); + } + + /// Alt-screen detection uses byte-pattern matching on the output + /// stream, not the VT grid, but we verify the state flips correctly + /// in passthrough mode anyway — the alt-screen code path branches on + /// `in_alternate_screen` and a missed transition would silently break + /// editor handoff. + #[test] + fn test_passthrough_still_tracks_alt_screen() { + use crate::escape_sequences::ALT_SCREEN_ENTER; + let (mut proxy, _rx, tx) = passthrough_proxy_with_capture(); + + assert!(!proxy.is_in_alternate_screen()); + proxy + .process_output(ALT_SCREEN_ENTER, &tx) + .expect("process_output"); + assert!( + proxy.is_in_alternate_screen(), + "alt-screen enter detection must work in passthrough mode" + ); + } + + /// Lookback replays the history buffer when the user toggles into + /// lookback mode. If passthrough mode skipped history feeding, the + /// buffer would be empty on entry and there'd be nothing to replay. + #[test] + fn test_passthrough_still_records_history() { + let (mut proxy, _rx, tx) = passthrough_proxy_with_capture(); + + proxy + .process_output(b"line for history\r\n", &tx) + .expect("process_output"); + + let mut history = Vec::new(); + proxy.history().append_all(&mut history); + let s = String::from_utf8_lossy(&history); + assert!( + s.contains("line for history"), + "passthrough mode must still record history for lookback, \ + got: {:?}", + s + ); + } + 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); diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index f00f52f..d421039 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -447,6 +447,16 @@ impl Proxy { Ok(()) } + #[cfg(test)] + pub(crate) fn set_passthrough_mode(&mut self, on: bool) { + self.passthrough_mode = on; + } + + #[cfg(test)] + pub(crate) fn vt_render_pending(&self) -> bool { + self.vt_render_pending + } + pub fn run(&mut self) -> Result { let stdin_fd = io::stdin(); let stdout_fd = io::stdout(); @@ -1222,20 +1232,14 @@ impl Drop for Proxy { fn drop(&mut self) { let stdout_fd = io::stdout(); - // Reset attribute state Claude (or claude-chill's own renderer) - // may have left active. Without these, an active fg/bg/hyperlink - // at exit leaks into the shell prompt and surrounding output — - // user-visible as garbled or invisible text after /exit. + // Reset any attribute state Claude (or claude-chill's renderer) + // may have left active so it doesn't leak into the shell prompt. + // Don't wipe the screen — once the palimpsest bug is fixed, the + // child's final output (e.g. Claude's "Bye!") is fine to leave on + // screen, matching how non-TUI CLIs behave at exit. let _ = write_all(&stdout_fd, b"\x1b]8;;\x1b\\"); let _ = write_all(&stdout_fd, b"\x1b[0m"); - // Wipe the visible viewport and home the cursor so the shell - // prompt lands on a clean canvas instead of overdrawing Claude's - // last UI frame. Scrollback is preserved (\x1b[2J only clears - // the viewport). - let _ = write_all(&stdout_fd, CLEAR_SCREEN); - let _ = write_all(&stdout_fd, CURSOR_HOME); - let _ = write_all(&stdout_fd, BRACKETED_PASTE_DISABLE); if let Some(ref termios) = self.original_termios {