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) {
|
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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user