Compare commits
3 Commits
master
...
colors-rew
| Author | SHA1 | Date | |
|---|---|---|---|
| 3841753187 | |||
| 3492b2b1c5 | |||
| 7e0accc032 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,3 +11,5 @@ pub mod line_buffer;
|
||||
pub mod proxy;
|
||||
pub mod redraw_throttler;
|
||||
pub mod screen_engine;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
44
crates/claude-chill/src/test_support.rs
Normal file
44
crates/claude-chill/src/test_support.rs
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user