Compare commits

..

1 Commits

Author SHA1 Message Date
3d58a79d4f Merge pull request 'colors-rewrite' (#1) from colors-rewrite into master
Some checks failed
CI / Nix Build (ubuntu-latest) (push) Has been cancelled
CI / Version Badge (push) Has been cancelled
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / test-matrix (macos-latest) (push) Has been cancelled
CI / test-matrix (ubuntu-latest) (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Integration Tests (macos-latest) (push) Has been cancelled
CI / Integration Tests (ubuntu-latest) (push) Has been cancelled
CI / Nix Build (macos-latest) (push) Has been cancelled
Reviewed-on: #1
2026-05-01 20:55:10 +00:00
7 changed files with 81 additions and 507 deletions

3
.gitignore vendored
View File

@@ -20,6 +20,3 @@ target
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
.claude .claude
# Local screenshots / scratch artifacts
/Screenshot*.png

View File

@@ -30,8 +30,18 @@ mod tests {
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use std::process::{Child, Command}; use std::process::{Child, Command};
fn spawn_echo_child() -> (OwnedFd, Child) { fn pty_mock_binary() -> Option<String> {
let bin = crate::test_support::pty_mock_binary(); let manifest_dir = env!("CARGO_MANIFEST_DIR");
let bin = format!("{}/../../target/debug/pty-mock", manifest_dir);
if std::path::Path::new(&bin).exists() {
Some(bin)
} else {
None
}
}
fn spawn_echo_child() -> Option<(OwnedFd, Child)> {
let bin = pty_mock_binary()?;
let pty = openpty(None, None).expect("openpty failed"); let pty = openpty(None, None).expect("openpty failed");
let slave_fd = pty.slave.as_raw_fd(); let slave_fd = pty.slave.as_raw_fd();
@@ -65,7 +75,7 @@ mod tests {
let flags = OFlag::from_bits_truncate(flags); let flags = OFlag::from_bits_truncate(flags);
fcntl(&pty.master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL"); fcntl(&pty.master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL");
(pty.master, child) Some((pty.master, child))
} }
fn make_proxy(master: OwnedFd, child: Child) -> Proxy { fn make_proxy(master: OwnedFd, child: Child) -> Proxy {
@@ -96,15 +106,15 @@ mod tests {
/// Feed `input` through the proxy, force a render to a captured pipe, /// Feed `input` through the proxy, force a render to a captured pipe,
/// and return what the proxy emitted. /// and return what the proxy emitted.
fn render_and_capture(input: &[u8]) -> Vec<u8> { fn render_and_capture(input: &[u8]) -> Option<Vec<u8>> {
let (master, child) = spawn_echo_child(); let (master, child) = spawn_echo_child()?;
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let (rx, tx) = capture_pipe(); let (rx, tx) = capture_pipe();
proxy.process_output(input, &tx).expect("process_output"); proxy.process_output(input, &tx).expect("process_output");
proxy.force_render(&tx).expect("force_render"); proxy.force_render(&tx).expect("force_render");
drain(&rx) Some(drain(&rx))
} }
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
@@ -120,7 +130,7 @@ mod tests {
input.extend_from_slice(b"link text"); input.extend_from_slice(b"link text");
input.extend_from_slice(b"\x1b]8;;\x1b\\"); input.extend_from_slice(b"\x1b]8;;\x1b\\");
let captured = render_and_capture(&input); let captured = render_and_capture(&input).expect("pty-mock binary missing");
assert!( assert!(
contains_bytes(&captured, b"\x1b]8;;https://example.com/foo"), contains_bytes(&captured, b"\x1b]8;;https://example.com/foo"),
"OSC 8 hyperlink open should survive render, got: {:?}", "OSC 8 hyperlink open should survive render, got: {:?}",
@@ -137,7 +147,7 @@ mod tests {
fn strikethrough_survives_render() { fn strikethrough_survives_render() {
// SGR 9 = strikethrough. vt100 0.16 has no field for it. // SGR 9 = strikethrough. vt100 0.16 has no field for it.
let input = b"\x1b[9mstruck through text\x1b[0m"; let input = b"\x1b[9mstruck through text\x1b[0m";
let captured = render_and_capture(input); let captured = render_and_capture(input).expect("pty-mock binary missing");
assert!( assert!(
contains_bytes(&captured, b"\x1b[9m") || contains_bytes(&captured, b";9m"), contains_bytes(&captured, b"\x1b[9m") || contains_bytes(&captured, b";9m"),
"SGR 9 strikethrough should survive render, got: {:?}", "SGR 9 strikethrough should survive render, got: {:?}",
@@ -150,7 +160,7 @@ mod tests {
fn curly_underline_survives_render() { fn curly_underline_survives_render() {
// SGR 4:3 = curly underline (kitty/wezterm/ghostty all support). // SGR 4:3 = curly underline (kitty/wezterm/ghostty all support).
let input = b"\x1b[4:3mcurly underline\x1b[0m"; let input = b"\x1b[4:3mcurly underline\x1b[0m";
let captured = render_and_capture(input); let captured = render_and_capture(input).expect("pty-mock binary missing");
assert!( assert!(
contains_bytes(&captured, b"4:3"), contains_bytes(&captured, b"4:3"),
"SGR 4:3 curly underline should survive render, got: {:?}", "SGR 4:3 curly underline should survive render, got: {:?}",
@@ -163,7 +173,7 @@ mod tests {
fn underline_color_survives_render() { fn underline_color_survives_render() {
// SGR 58:2::R:G:B = truecolor underline color. // SGR 58:2::R:G:B = truecolor underline color.
let input = b"\x1b[4m\x1b[58:2::255:0:0mcolored underline\x1b[0m"; let input = b"\x1b[4m\x1b[58:2::255:0:0mcolored underline\x1b[0m";
let captured = render_and_capture(input); let captured = render_and_capture(input).expect("pty-mock binary missing");
assert!( assert!(
contains_bytes(&captured, b"58:2") || contains_bytes(&captured, b"58;2"), contains_bytes(&captured, b"58:2") || contains_bytes(&captured, b"58;2"),
"SGR 58 underline color should survive render, got: {:?}", "SGR 58 underline color should survive render, got: {:?}",
@@ -175,7 +185,7 @@ mod tests {
fn double_underline_survives_render() { fn double_underline_survives_render() {
// SGR 21 = doubly underlined. // SGR 21 = doubly underlined.
let input = b"\x1b[21mdouble underlined\x1b[0m"; let input = b"\x1b[21mdouble underlined\x1b[0m";
let captured = render_and_capture(input); let captured = render_and_capture(input).expect("pty-mock binary missing");
assert!( assert!(
contains_bytes(&captured, b"\x1b[21m") contains_bytes(&captured, b"\x1b[21m")
|| contains_bytes(&captured, b";21m") || contains_bytes(&captured, b";21m")
@@ -192,7 +202,10 @@ mod tests {
fn truecolor_foreground_survives_render() { fn truecolor_foreground_survives_render() {
// SGR 38;2;R;G;B = truecolor foreground. vt100 supports this. // SGR 38;2;R;G;B = truecolor foreground. vt100 supports this.
let input = b"\x1b[38;2;255;128;0morange text\x1b[0m"; let input = b"\x1b[38;2;255;128;0morange text\x1b[0m";
let captured = render_and_capture(input); let Some(captured) = render_and_capture(input) else {
// pty-mock not built — skip.
return;
};
assert!( assert!(
contains_bytes(&captured, b"38;2;255;128;0") contains_bytes(&captured, b"38;2;255;128;0")
|| contains_bytes(&captured, b"38:2::255:128:0"), || contains_bytes(&captured, b"38:2::255:128:0"),

View File

@@ -11,8 +11,13 @@ mod tests {
use std::process::{Child, Command}; use std::process::{Child, Command};
use std::time::Duration; use std::time::Duration;
fn pty_mock_binary() -> String { fn pty_mock_binary() -> Option<String> {
crate::test_support::pty_mock_binary() 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) { fn spawn_command(cmd: &str, args: &[&str]) -> (OwnedFd, Child) {
@@ -53,8 +58,9 @@ mod tests {
(pty.master, child) (pty.master, child)
} }
fn spawn_mock(subcommand: &str) -> (OwnedFd, Child) { fn spawn_mock(subcommand: &str) -> Option<(OwnedFd, Child)> {
spawn_command(&pty_mock_binary(), &[subcommand]) let bin = pty_mock_binary()?;
Some(spawn_command(&bin, &[subcommand]))
} }
fn read_pty_output(master: &OwnedFd, timeout_ms: u16) -> Vec<u8> { fn read_pty_output(master: &OwnedFd, timeout_ms: u16) -> Vec<u8> {
@@ -98,7 +104,9 @@ mod tests {
#[test] #[test]
fn test_echo_baseline() { fn test_echo_baseline() {
let (master, child) = spawn_mock("echo"); let Some((master, child)) = spawn_mock("echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -128,7 +136,9 @@ mod tests {
#[test] #[test]
fn test_alt_screen_tracking() { fn test_alt_screen_tracking() {
let (master, child) = spawn_mock("alt-screen"); let Some((master, child)) = spawn_mock("alt-screen") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -168,7 +178,9 @@ mod tests {
#[test] #[test]
fn test_bracketed_paste_flow() { fn test_bracketed_paste_flow() {
let (master, child) = spawn_mock("paste-echo"); let Some((master, child)) = spawn_mock("paste-echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -197,7 +209,9 @@ mod tests {
#[test] #[test]
fn test_split_paste_end_marker() { fn test_split_paste_end_marker() {
let (master, child) = spawn_mock("paste-echo"); let Some((master, child)) = spawn_mock("paste-echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -238,7 +252,9 @@ mod tests {
#[test] #[test]
fn test_alt_screen_exit_restores_main_content() { fn test_alt_screen_exit_restores_main_content() {
let (master, child) = spawn_mock("echo"); let Some((master, child)) = spawn_mock("echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -293,7 +309,9 @@ mod tests {
// in the next chunk. VT parser saves blank screen. After alt exit, // in the next chunk. VT parser saves blank screen. After alt exit,
// contents_formatted() paints blank over the real terminal which // contents_formatted() paints blank over the real terminal which
// still had the original content (it never got the clear render). // still had the original content (it never got the clear render).
let (master, child) = spawn_mock("echo"); let Some((master, child)) = spawn_mock("echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -365,7 +383,9 @@ mod tests {
#[test] #[test]
fn test_sync_block_tracking() { fn test_sync_block_tracking() {
let (master, child) = spawn_mock("echo"); let Some((master, child)) = spawn_mock("echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -390,7 +410,9 @@ mod tests {
#[test] #[test]
fn test_lookback_mode_toggle() { fn test_lookback_mode_toggle() {
let (master, child) = spawn_mock("echo"); let Some((master, child)) = spawn_mock("echo") else {
return;
};
let mut proxy = make_proxy(master, child); let mut proxy = make_proxy(master, child);
let sink = dev_null(); let sink = dev_null();
@@ -416,238 +438,6 @@ mod tests {
); );
} }
/// Regression: exiting lookback mode must wipe the visible pane before
/// the next VT diff render. Without the explicit CLEAR_SCREEN, blank
/// cells in the new screen state can be diffed-as-equal against
/// pre-existing characters left over from the lookback dump, leaking
/// stale content into the live UI.
#[test]
fn test_lookback_exit_emits_clear_screen() {
use crate::escape_sequences::{CLEAR_SCREEN, CURSOR_HOME, SYNC_START};
use nix::unistd::pipe;
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
// Capture the proxy's "stdout" so we can inspect what it writes
// when exiting lookback mode.
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");
// Seed some history so lookback has content to dump.
proxy
.process_output(b"line 1\r\nline 2\r\nline 3\r\n", &tx)
.expect("output");
// Enter lookback.
send_input(&mut proxy, &tx, &[0x1E]);
assert!(proxy.is_in_lookback_mode());
// Drain whatever was emitted up to this point so we only inspect
// bytes from the exit path.
let mut drain_buf = [0u8; 65536];
loop {
match nix::unistd::read(rx.as_fd(), &mut drain_buf) {
Ok(0) => break,
Ok(_) => continue,
Err(_) => break,
}
}
// Exit lookback. The proxy should write CLEAR_SCREEN + CURSOR_HOME
// to wipe the lookback dump before the next VT render.
send_input(&mut proxy, &tx, &[0x1E]);
assert!(!proxy.is_in_lookback_mode());
let mut captured = Vec::new();
let mut buf = [0u8; 65536];
loop {
match nix::unistd::read(rx.as_fd(), &mut buf) {
Ok(0) => break,
Ok(n) => captured.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
let clear_pos = captured
.windows(CLEAR_SCREEN.len())
.position(|w| w == CLEAR_SCREEN);
let home_pos = captured
.windows(CURSOR_HOME.len())
.position(|w| w == CURSOR_HOME);
assert!(
clear_pos.is_some(),
"lookback exit should emit CLEAR_SCREEN, got: {:?}",
String::from_utf8_lossy(&captured)
);
assert!(
home_pos.is_some(),
"lookback exit should emit CURSOR_HOME, got: {:?}",
String::from_utf8_lossy(&captured)
);
// The whole point of the clear is to wipe the lookback dump
// *before* the next VT diff repaints. If the repaint went out
// first, the diff would have been computed against the live VT
// state but landed on a terminal still showing the dump — the
// clear that follows would erase the freshly-painted UI. So
// assert ordering: CLEAR/HOME come strictly before the repaint
// payload (the rendered "line 1" content).
// The repaint payload is wrapped by the proxy in BSU/ESU
// (SYNC_START / SYNC_END), so its leading byte sequence is a
// reliable marker for "where the render starts" — the renderer
// itself optimizes "line 1" into "line" + cursor-move + "1",
// which makes a literal text search unreliable.
let sync_start_pos = captured
.windows(SYNC_START.len())
.position(|w| w == SYNC_START)
.expect("forced repaint should be wrapped in SYNC_START");
assert!(
clear_pos.unwrap() < sync_start_pos,
"CLEAR_SCREEN must precede repaint payload, got clear@{:?} sync@{}",
clear_pos,
sync_start_pos
);
assert!(
home_pos.unwrap() < sync_start_pos,
"CURSOR_HOME must precede repaint payload, got home@{:?} sync@{}",
home_pos,
sync_start_pos
);
}
/// 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

@@ -11,5 +11,3 @@ pub mod line_buffer;
pub mod proxy; pub mod proxy;
pub mod redraw_throttler; pub mod redraw_throttler;
pub mod screen_engine; pub mod screen_engine;
#[cfg(test)]
mod test_support;

View File

@@ -93,7 +93,7 @@ impl Drop for TerminalGuard {
} }
} }
const RENDER_DELAY_MS: u64 = 33; const RENDER_DELAY_MS: u64 = 5;
const SYNC_BLOCK_DELAY_MS: u64 = 50; const SYNC_BLOCK_DELAY_MS: u64 = 50;
pub struct Proxy { pub struct Proxy {
@@ -133,15 +133,6 @@ pub struct Proxy {
paste_end_finder: memmem::Finder<'static>, paste_end_finder: memmem::Finder<'static>,
pty_drain_buffer: Vec<u8>, pty_drain_buffer: Vec<u8>,
paste_remainder: Vec<u8>, paste_remainder: Vec<u8>,
/// True when running under tmux ($TMUX is set). In this mode we forward
/// child output bytes directly to the terminal instead of differentially
/// re-rendering through wezterm-term + termwiz, because tmux is also
/// tracking the cell grid and termwiz's `Surface::diff_lines` doesn't
/// emit clearing Changes for cells that became blank — the resulting
/// stale-cell drift turns into a palimpsest in tmux's view of the pane.
/// Lookback (history dump + one-shot full repaint on exit) still uses
/// the rendered path; only steady-state output goes raw.
passthrough_mode: bool,
} }
/// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode /// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode
@@ -279,13 +270,6 @@ impl Proxy {
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?; write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
// Wipe whatever was on the terminal before we started. The first VT
// render is a diff against an empty mirror, which only paints cells
// that differ from blank — pre-existing characters that line up with
// blank cells in the new render survive as overlap artifacts.
write_all(&stdout_fd, CLEAR_SCREEN)?;
write_all(&stdout_fd, CURSOR_HOME)?;
let screen = ScreenEngine::new(winsize.ws_row, winsize.ws_col); let screen = ScreenEngine::new(winsize.ws_row, winsize.ws_col);
// Seed history with clear screen so replay starts fresh // Seed history with clear screen so replay starts fresh
@@ -295,11 +279,6 @@ impl Proxy {
let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms); let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms);
let passthrough_mode = std::env::var_os("TMUX").is_some();
if passthrough_mode {
debug!("Proxy::spawn: $TMUX detected, enabling passthrough render mode");
}
debug!("Proxy::spawn: command={} args={:?}", command, args); debug!("Proxy::spawn: command={} args={:?}", command, args);
Ok(Self { Ok(Self {
@@ -339,7 +318,6 @@ impl Proxy {
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END), paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
pty_drain_buffer: Vec::new(), pty_drain_buffer: Vec::new(),
paste_remainder: Vec::new(), paste_remainder: Vec::new(),
passthrough_mode,
}) })
} }
@@ -394,7 +372,6 @@ impl Proxy {
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END), paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
pty_drain_buffer: Vec::new(), pty_drain_buffer: Vec::new(),
paste_remainder: Vec::new(), paste_remainder: Vec::new(),
passthrough_mode: false,
} }
} }
@@ -447,16 +424,6 @@ 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();
@@ -578,16 +545,7 @@ impl Proxy {
if feed_vt { if feed_vt {
self.screen.process(data); self.screen.process(data);
} }
if self.passthrough_mode {
// Under tmux: forward bytes raw to the real terminal instead of
// queueing a diff render. tmux is the smart emulator and the
// termwiz diff path produces palimpsest artifacts in its grid.
// The wezterm-term grid is still kept current above so lookback
// / one-shot full repaints work.
self.write_to_terminal(stdout_fd, data)?;
} else {
self.vt_render_pending = true; self.vt_render_pending = true;
}
self.last_output_time = Some(Instant::now()); self.last_output_time = Some(Instant::now());
// Process sync blocks for history management // Process sync blocks for history management
@@ -1182,11 +1140,6 @@ impl Proxy {
self.forward_winsize()?; self.forward_winsize()?;
// Clear the lookback dump out of the visible pane so blank cells in
// the new VT state don't leak old history through.
self.write_to_terminal(stdout_fd, CLEAR_SCREEN)?;
self.write_to_terminal(stdout_fd, CURSOR_HOME)?;
// Force full render since terminal was showing history // Force full render since terminal was showing history
debug!("exit_lookback_mode: rendering VT screen"); debug!("exit_lookback_mode: rendering VT screen");
self.screen.force_full_next(); self.screen.force_full_next();
@@ -1230,16 +1183,8 @@ impl Proxy {
impl Drop for Proxy { impl Drop for Proxy {
fn drop(&mut self) { fn drop(&mut self) {
// Disable bracketed paste before restoring terminal
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
// 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");
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 {

View File

@@ -17,24 +17,12 @@ use anyhow::Result;
use std::io::Write; use std::io::Write;
use std::sync::Arc; use std::sync::Arc;
use termwiz::caps::{Capabilities, ColorLevel, ProbeHints}; use termwiz::caps::{Capabilities, ColorLevel, ProbeHints};
use termwiz::cell::CellAttributes;
use termwiz::render::RenderTty; use termwiz::render::RenderTty;
use termwiz::render::terminfo::TerminfoRenderer; use termwiz::render::terminfo::TerminfoRenderer;
use termwiz::surface::{Change, Position, Surface}; use termwiz::surface::{Change, Position, Surface};
use wezterm_term::color::ColorPalette; use wezterm_term::color::ColorPalette;
use wezterm_term::{Terminal, TerminalConfiguration, TerminalSize}; use wezterm_term::{Terminal, TerminalConfiguration, TerminalSize};
fn make_renderer() -> TerminfoRenderer {
let caps = Capabilities::new_with_hints(
ProbeHints::default()
.color_level(Some(ColorLevel::TrueColor))
.hyperlinks(Some(true))
.bracketed_paste(Some(true)),
)
.expect("Capabilities::new_with_hints");
TerminfoRenderer::new(caps)
}
#[derive(Debug)] #[derive(Debug)]
struct ProxyTermConfig; struct ProxyTermConfig;
@@ -105,7 +93,15 @@ impl ScreenEngine {
); );
let mirror = Surface::new(cols, rows); let mirror = Surface::new(cols, rows);
let renderer = make_renderer();
let caps = Capabilities::new_with_hints(
ProbeHints::default()
.color_level(Some(ColorLevel::TrueColor))
.hyperlinks(Some(true))
.bracketed_paste(Some(true)),
)
.expect("Capabilities::new_with_hints");
let renderer = TerminfoRenderer::new(caps);
Self { Self {
terminal, terminal,
@@ -154,15 +150,8 @@ impl ScreenEngine {
let line_refs: Vec<&_> = lines.iter().collect(); let line_refs: Vec<&_> = lines.iter().collect();
// If forced, reset the mirror so diff_lines emits the full screen. // If forced, reset the mirror so diff_lines emits the full screen.
// Also recreate the renderer: TerminfoRenderer caches CellAttributes
// across render_to calls so it can suppress redundant SGR emission.
// When force_full is set the terminal has been mutated out-of-band
// (startup, post-resize, alt-screen child output, raw CLEAR_SCREEN
// on lookback exit), so the cached attribute state can't be trusted.
// Recreate the renderer to start from a known-default pen.
if self.force_full { if self.force_full {
self.mirror = Surface::new(self.cols, self.rows); self.mirror = Surface::new(self.cols, self.rows);
self.renderer = make_renderer();
self.force_full = false; self.force_full = false;
} }
@@ -177,14 +166,9 @@ impl ScreenEngine {
y: Position::Absolute(row), y: Position::Absolute(row),
}); });
// Reset attributes (and close any active hyperlink) at the end of // Keep the mirror in sync with what we just emitted (minus the final
// the frame, routed through the renderer's state model so its // cursor reposition, which isn't a content change).
// cached current_attr stays in sync with what we emit. Without self.mirror.add_changes(changes.clone());
// this, a diff that ends inside a styled/linked cell leaves the
// renderer thinking those attributes are still active, and the
// next render's SGR transitions get computed against the wrong
// baseline — manifesting as bg-color smears or sticky links.
changes.push(Change::AllAttributes(CellAttributes::default()));
let mut tty = CapturedTty { let mut tty = CapturedTty {
buf: Vec::new(), buf: Vec::new(),
@@ -195,21 +179,14 @@ impl ScreenEngine {
.render_to(&changes, &mut tty) .render_to(&changes, &mut tty)
.map_err(|e| anyhow::anyhow!("renderer: {}", e))?; .map_err(|e| anyhow::anyhow!("renderer: {}", e))?;
// Advance the mirror only after render_to succeeds. If serialization // Defensive OSC 8 close: TerminfoRenderer only emits the hyperlink
// fails, the next diff will be computed against a state that // close when transitioning to a non-hyperlinked cell. If our diff
// matches what was actually shown — no "assumed emitted" drift. // ends inside the hyperlinked region (rest of the row was blank
// (CursorPosition / AllAttributes both flow through Surface, so // and didn't need updating), the close is never emitted and the
// the mirror's own pen state tracks the renderer's.) // downstream terminal may keep the hyperlink active across the
self.mirror.add_changes(changes); // boundary. Appending a close here is a no-op when there's no
// active hyperlink.
// Belt-and-suspenders wire reset. AllAttributes(default) updates
// the renderer's pen, but the renderer is allowed to defer the
// actual byte emission until the next cell paint. These raw
// sequences guarantee the wire ends each frame at default attrs,
// so styles can't leak into any passthrough output that follows
// before the next render. They're no-ops when nothing is active.
tty.buf.extend_from_slice(b"\x1b]8;;\x1b\\"); tty.buf.extend_from_slice(b"\x1b]8;;\x1b\\");
tty.buf.extend_from_slice(b"\x1b[0m");
Ok(tty.buf) Ok(tty.buf)
} }
@@ -284,106 +261,4 @@ mod tests {
String::from_utf8_lossy(&out) String::from_utf8_lossy(&out)
); );
} }
/// Regression: TerminfoRenderer caches CellAttributes across `render_to`
/// calls. We append a raw `\x1b[0m` to the wire after each frame, so on
/// the next frame the wire is at default attrs but the renderer's
/// cached `current_attr` could disagree if we don't keep them in sync.
/// Specifically, a styled run that lands again on the second frame must
/// re-emit its SGR — otherwise the renderer suppresses the transition
/// (thinking "already active") and the styled text lands unstyled.
#[test]
fn sgr_reemitted_on_second_frame_after_wire_reset() {
let mut e = ScreenEngine::new(24, 80);
// Frame 1: render red text. Should emit SGR 31 and the trailing
// `\x1b[0m` resets the wire.
e.process(b"\x1b[31mred\x1b[0m\r\n");
let frame1 = e.render().expect("frame1");
assert!(
contains(&frame1, b"[31m") || contains(&frame1, b";31m"),
"frame1 should contain red SGR, got: {:?}",
String::from_utf8_lossy(&frame1)
);
assert!(
frame1.ends_with(b"\x1b[0m"),
"frame1 should end with SGR 0 to reset the wire"
);
// Frame 2: render different red text on a new line. Renderer's
// pen is reset to default by frame1's trailing AllAttributes, so
// the SGR 31 must be re-emitted to bring the wire to red again.
e.process(b"\x1b[31mmore red\x1b[0m\r\n");
let frame2 = e.render().expect("frame2");
assert!(
contains(&frame2, b"[31m") || contains(&frame2, b";31m"),
"frame2 must re-emit red SGR — renderer state drift would \
suppress it but the wire was reset, got: {:?}",
String::from_utf8_lossy(&frame2)
);
}
/// Regression: a hyperlink that's still active on the next frame must
/// re-emit the OSC 8 open. The trailing `\x1b]8;;\x1b\\` after each
/// frame closes the link on the wire; if we don't keep the renderer's
/// model in sync via `Change::AllAttributes(default)`, the renderer
/// thinks the link is still active and skips the open on the next frame.
#[test]
fn hyperlink_reemitted_on_second_frame_after_wire_close() {
let mut e = ScreenEngine::new(24, 80);
let mut link1 = Vec::new();
link1.extend_from_slice(b"\x1b]8;;https://a.example\x1b\\");
link1.extend_from_slice(b"first\x1b]8;;\x1b\\\r\n");
e.process(&link1);
let frame1 = e.render().expect("frame1");
assert!(
contains(&frame1, b"\x1b]8;;https://a.example"),
"frame1 should contain hyperlink open"
);
let mut link2 = Vec::new();
link2.extend_from_slice(b"\x1b]8;;https://b.example\x1b\\");
link2.extend_from_slice(b"second\x1b]8;;\x1b\\\r\n");
e.process(&link2);
let frame2 = e.render().expect("frame2");
assert!(
contains(&frame2, b"\x1b]8;;https://b.example"),
"frame2 must emit the new hyperlink open — renderer drift \
would suppress it after the prior wire close, got: {:?}",
String::from_utf8_lossy(&frame2)
);
}
/// Regression: when `force_full_next()` fires (lookback exit, alt-screen
/// exit, resize), the renderer is recreated so out-of-band terminal
/// writes can't leave stale `current_attr`. This test sanity-checks
/// that a forced full render after styled content emits a complete
/// frame including the SGR — not a "no diff" because the renderer
/// thought the style was already on the wire.
#[test]
fn forced_full_render_reemits_sgr_after_renderer_recreate() {
let mut e = ScreenEngine::new(24, 80);
e.process(b"\x1b[34mblue text\x1b[0m\r\n");
let _ = e.render().expect("frame1");
// Simulate the proxy path: out-of-band CLEAR_SCREEN was just sent
// to the real terminal, and force_full_next was called.
e.force_full_next();
// Render again. Even though no new bytes came in, the forced full
// render must emit SGR for the styled cells — they're being
// repainted from scratch onto a freshly-cleared terminal.
let frame2 = e.render().expect("frame2");
assert!(
contains(&frame2, b"[34m") || contains(&frame2, b";34m"),
"forced full render must re-emit SGR 34 (blue), got: {:?}",
String::from_utf8_lossy(&frame2)
);
assert!(
contains(&frame2, b"blue text"),
"forced full render must include the cell content, got: {:?}",
String::from_utf8_lossy(&frame2)
);
}
} }

View File

@@ -1,44 +0,0 @@
//! Shared helpers for `#[cfg(test)]` modules.
use std::path::PathBuf;
/// Locate the `pty-mock` binary built by the workspace.
///
/// Honors `CARGO_TARGET_DIR` and falls back to the workspace's `target/`
/// next to `crates/`. Tries `debug/` first, then `release/`. Panics with
/// a clear instruction if the binary isn't found — silently skipping
/// would let CI go green without exercising the PTY-driven coverage that
/// these tests exist to provide.
pub fn pty_mock_binary() -> String {
let exe_name = if cfg!(windows) {
"pty-mock.exe"
} else {
"pty-mock"
};
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
let base = PathBuf::from(target_dir);
candidates.push(base.join("debug").join(exe_name));
candidates.push(base.join("release").join(exe_name));
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_target = manifest_dir.join("../../target");
candidates.push(workspace_target.join("debug").join(exe_name));
candidates.push(workspace_target.join("release").join(exe_name));
for path in &candidates {
if path.exists() {
return path.to_string_lossy().into_owned();
}
}
panic!(
"pty-mock binary not found. Build it first with `cargo build -p pty-mock` \
(or set CARGO_TARGET_DIR if you use a non-default target dir). \
Searched: {:?}",
candidates
);
}