3 Commits

Author SHA1 Message Date
3841753187 Ignore local screenshot artifacts in repo root
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:37:19 +00:00
3492b2b1c5 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>
2026-05-26 17:38:11 +00:00
7e0accc032 fix: tmux passthrough + renderer state drift + exit cleanup
Investigating "weird redraw issues" surfaced by the wezterm-term swap
turned up four separate bugs in the proxy↔terminal byte path:

1. TerminfoRenderer caches CellAttributes across render_to calls.
   The trailing raw `\x1b[0m` and out-of-band CLEAR_SCREEN writes
   left its `current_attr` desynced from the real terminal — next
   frame's SGR transitions were computed against the wrong baseline,
   manifesting as bg-color smears and sticky links. Route the reset
   through `Change::AllAttributes(default)` so the renderer's pen
   stays in sync, and recreate the renderer whenever `force_full`
   fires (startup, lookback exit, alt exit, resize) so the cached
   state can't outlive an out-of-band write.

2. Proxy startup didn't clear the terminal, so the first VT diff
   landed on top of whatever the user's shell had on screen. Wipe
   on spawn alongside enabling bracketed paste.

3. `Drop` only disabled bracketed paste and restored termios, so an
   exit mid-render leaked active styles into the shell prompt and
   left Claude's last UI frame on screen. Add OSC-8 close → SGR 0 →
   CLEAR_SCREEN → CURSOR_HOME before the existing teardown.

4. Under tmux, steady-state diff renders produced a palimpsest of
   stacked frames because termwiz's `Surface::diff_lines` only walks
   the new frame's *visible* cells — cells that became blank don't
   generate clearing Changes. tmux is also tracking the grid and is
   sensitive to the partial repaint stream. Detect `$TMUX` at spawn
   and switch to passthrough: feed wezterm-term for state but
   forward raw child bytes to stdout. Lookback's one-shot full
   repaint still uses the renderer (screen is cleared first, so
   the diff_lines blank-cell behavior is fine).

Test infrastructure:
- New shared `test_support::pty_mock_binary()` honors CARGO_TARGET_DIR,
  tries debug/release, and panics with an actionable message instead
  of returning Option<String>. All 8 `let Some(...) else { return; }`
  silent-skip patterns replaced with direct destructure.
- Mirror is now advanced after `render_to` succeeds, not before.

New regression tests:
- `sgr_reemitted_on_second_frame_after_wire_reset`
- `hyperlink_reemitted_on_second_frame_after_wire_close`
- `forced_full_render_reemits_sgr_after_renderer_recreate`
- `test_lookback_exit_emits_clear_screen` asserts CLEAR/HOME precede
  the SYNC-wrapped repaint payload.

122 passing / 0 failing / 4 ignored (the two pre-existing
underline-style cases plus the claude-CLI-required integration).
2026-05-03 06:08:27 +00:00
7 changed files with 507 additions and 81 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@@ -11,13 +11,8 @@ mod tests {
use std::process::{Child, Command};
use std::time::Duration;
fn pty_mock_binary() -> Option<String> {
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 pty_mock_binary() -> String {
crate::test_support::pty_mock_binary()
}
fn spawn_command(cmd: &str, args: &[&str]) -> (OwnedFd, Child) {
@@ -58,9 +53,8 @@ mod tests {
(pty.master, child)
}
fn spawn_mock(subcommand: &str) -> Option<(OwnedFd, Child)> {
let bin = pty_mock_binary()?;
Some(spawn_command(&bin, &[subcommand]))
fn spawn_mock(subcommand: &str) -> (OwnedFd, Child) {
spawn_command(&pty_mock_binary(), &[subcommand])
}
fn read_pty_output(master: &OwnedFd, timeout_ms: u16) -> Vec<u8> {
@@ -104,9 +98,7 @@ mod tests {
#[test]
fn test_echo_baseline() {
let Some((master, child)) = spawn_mock("echo") else {
return;
};
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -136,9 +128,7 @@ mod tests {
#[test]
fn test_alt_screen_tracking() {
let Some((master, child)) = spawn_mock("alt-screen") else {
return;
};
let (master, child) = spawn_mock("alt-screen");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -178,9 +168,7 @@ mod tests {
#[test]
fn test_bracketed_paste_flow() {
let Some((master, child)) = spawn_mock("paste-echo") else {
return;
};
let (master, child) = spawn_mock("paste-echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -209,9 +197,7 @@ mod tests {
#[test]
fn test_split_paste_end_marker() {
let Some((master, child)) = spawn_mock("paste-echo") else {
return;
};
let (master, child) = spawn_mock("paste-echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -252,9 +238,7 @@ mod tests {
#[test]
fn test_alt_screen_exit_restores_main_content() {
let Some((master, child)) = spawn_mock("echo") else {
return;
};
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -309,9 +293,7 @@ mod tests {
// in the next chunk. VT parser saves blank screen. After alt exit,
// contents_formatted() paints blank over the real terminal which
// still had the original content (it never got the clear render).
let Some((master, child)) = spawn_mock("echo") else {
return;
};
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -383,9 +365,7 @@ mod tests {
#[test]
fn test_sync_block_tracking() {
let Some((master, child)) = spawn_mock("echo") else {
return;
};
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -410,9 +390,7 @@ mod tests {
#[test]
fn test_lookback_mode_toggle() {
let Some((master, child)) = spawn_mock("echo") else {
return;
};
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
@@ -438,6 +416,238 @@ 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) {
let master_dup = dup_master(proxy);
let output = read_pty_output(&master_dup, timeout_ms);

View File

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

View File

@@ -93,7 +93,7 @@ impl Drop for TerminalGuard {
}
}
const RENDER_DELAY_MS: u64 = 5;
const RENDER_DELAY_MS: u64 = 33;
const SYNC_BLOCK_DELAY_MS: u64 = 50;
pub struct Proxy {
@@ -133,6 +133,15 @@ pub struct Proxy {
paste_end_finder: memmem::Finder<'static>,
pty_drain_buffer: 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
@@ -270,6 +279,13 @@ impl Proxy {
let stdout_fd = io::stdout();
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);
// Seed history with clear screen so replay starts fresh
@@ -279,6 +295,11 @@ impl Proxy {
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);
Ok(Self {
@@ -318,6 +339,7 @@ impl Proxy {
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
pty_drain_buffer: Vec::new(),
paste_remainder: Vec::new(),
passthrough_mode,
})
}
@@ -372,6 +394,7 @@ impl Proxy {
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
pty_drain_buffer: Vec::new(),
paste_remainder: Vec::new(),
passthrough_mode: false,
}
}
@@ -424,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();
@@ -545,7 +578,16 @@ impl Proxy {
if feed_vt {
self.screen.process(data);
}
self.vt_render_pending = true;
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.last_output_time = Some(Instant::now());
// Process sync blocks for history management
@@ -1140,6 +1182,11 @@ impl Proxy {
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
debug!("exit_lookback_mode: rendering VT screen");
self.screen.force_full_next();
@@ -1183,8 +1230,16 @@ impl Proxy {
impl Drop for Proxy {
fn drop(&mut self) {
// Disable bracketed paste before restoring terminal
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);
if let Some(ref termios) = self.original_termios {

View File

@@ -17,12 +17,24 @@ use anyhow::Result;
use std::io::Write;
use std::sync::Arc;
use termwiz::caps::{Capabilities, ColorLevel, ProbeHints};
use termwiz::cell::CellAttributes;
use termwiz::render::RenderTty;
use termwiz::render::terminfo::TerminfoRenderer;
use termwiz::surface::{Change, Position, Surface};
use wezterm_term::color::ColorPalette;
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)]
struct ProxyTermConfig;
@@ -93,15 +105,7 @@ impl ScreenEngine {
);
let mirror = Surface::new(cols, rows);
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);
let renderer = make_renderer();
Self {
terminal,
@@ -150,8 +154,15 @@ impl ScreenEngine {
let line_refs: Vec<&_> = lines.iter().collect();
// 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 {
self.mirror = Surface::new(self.cols, self.rows);
self.renderer = make_renderer();
self.force_full = false;
}
@@ -166,9 +177,14 @@ impl ScreenEngine {
y: Position::Absolute(row),
});
// Keep the mirror in sync with what we just emitted (minus the final
// cursor reposition, which isn't a content change).
self.mirror.add_changes(changes.clone());
// Reset attributes (and close any active hyperlink) at the end of
// the frame, routed through the renderer's state model so its
// cached current_attr stays in sync with what we emit. Without
// 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 {
buf: Vec::new(),
@@ -179,14 +195,21 @@ impl ScreenEngine {
.render_to(&changes, &mut tty)
.map_err(|e| anyhow::anyhow!("renderer: {}", e))?;
// Defensive OSC 8 close: TerminfoRenderer only emits the hyperlink
// close when transitioning to a non-hyperlinked cell. If our diff
// ends inside the hyperlinked region (rest of the row was blank
// and didn't need updating), the close is never emitted and the
// downstream terminal may keep the hyperlink active across the
// boundary. Appending a close here is a no-op when there's no
// active hyperlink.
// Advance the mirror only after render_to succeeds. If serialization
// fails, the next diff will be computed against a state that
// matches what was actually shown — no "assumed emitted" drift.
// (CursorPosition / AllAttributes both flow through Surface, so
// the mirror's own pen state tracks the renderer's.)
self.mirror.add_changes(changes);
// 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[0m");
Ok(tty.buf)
}
@@ -261,4 +284,106 @@ mod tests {
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

@@ -0,0 +1,44 @@
//! 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
);
}