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:
@@ -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) {
|
||||
let master_dup = dup_master(proxy);
|
||||
let output = read_pty_output(&master_dup, timeout_ms);
|
||||
|
||||
@@ -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<i32> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user