Add passthrough-mode tests; stop wiping screen on exit

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 17:38:11 +00:00
parent 7e0accc032
commit 3492b2b1c5
2 changed files with 145 additions and 11 deletions

View File

@@ -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<u8> {
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) { fn pump_proxy(proxy: &mut Proxy, sink: &OwnedFd, timeout_ms: u16) {
let master_dup = dup_master(proxy); let master_dup = dup_master(proxy);
let output = read_pty_output(&master_dup, timeout_ms); let output = read_pty_output(&master_dup, timeout_ms);

View File

@@ -447,6 +447,16 @@ impl Proxy {
Ok(()) 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<i32> { pub fn run(&mut self) -> Result<i32> {
let stdin_fd = io::stdin(); let stdin_fd = io::stdin();
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
@@ -1222,20 +1232,14 @@ impl Drop for Proxy {
fn drop(&mut self) { fn drop(&mut self) {
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
// Reset attribute state Claude (or claude-chill's own renderer) // Reset any attribute state Claude (or claude-chill's renderer)
// may have left active. Without these, an active fg/bg/hyperlink // may have left active so it doesn't leak into the shell prompt.
// at exit leaks into the shell prompt and surrounding output — // Don't wipe the screen — once the palimpsest bug is fixed, the
// user-visible as garbled or invisible text after /exit. // 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]8;;\x1b\\");
let _ = write_all(&stdout_fd, b"\x1b[0m"); 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); let _ = write_all(&stdout_fd, BRACKETED_PASTE_DISABLE);
if let Some(ref termios) = self.original_termios { if let Some(ref termios) = self.original_termios {