diff --git a/crates/claude-chill/src/color_preservation_tests.rs b/crates/claude-chill/src/color_preservation_tests.rs index 260c8c8..18c5a7c 100644 --- a/crates/claude-chill/src/color_preservation_tests.rs +++ b/crates/claude-chill/src/color_preservation_tests.rs @@ -30,18 +30,8 @@ mod tests { use std::os::unix::process::CommandExt; use std::process::{Child, Command}; - fn pty_mock_binary() -> Option { - 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> { - let (master, child) = spawn_echo_child()?; + fn render_and_capture(input: &[u8]) -> Vec { + 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"), diff --git a/crates/claude-chill/src/integration_tests.rs b/crates/claude-chill/src/integration_tests.rs index 259c224..8e7c522 100644 --- a/crates/claude-chill/src/integration_tests.rs +++ b/crates/claude-chill/src/integration_tests.rs @@ -11,13 +11,8 @@ mod tests { use std::process::{Child, Command}; use std::time::Duration; - fn pty_mock_binary() -> Option { - 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 { @@ -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,108 @@ 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 + ); + } + 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); diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index 9e9d64e..783b977 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -11,3 +11,5 @@ pub mod line_buffer; pub mod proxy; pub mod redraw_throttler; pub mod screen_engine; +#[cfg(test)] +mod test_support; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 5e21a25..f00f52f 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -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, paste_remainder: Vec, + /// 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, } } @@ -545,7 +568,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 +1172,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 +1220,22 @@ impl Proxy { impl Drop for Proxy { fn drop(&mut self) { - // Disable bracketed paste before restoring terminal let stdout_fd = io::stdout(); + + // Reset attribute state Claude (or claude-chill's own renderer) + // may have left active. Without these, an active fg/bg/hyperlink + // at exit leaks into the shell prompt and surrounding output — + // user-visible as garbled or invisible text after /exit. + let _ = write_all(&stdout_fd, b"\x1b]8;;\x1b\\"); + let _ = write_all(&stdout_fd, b"\x1b[0m"); + + // Wipe the visible viewport and home the cursor so the shell + // prompt lands on a clean canvas instead of overdrawing Claude's + // last UI frame. Scrollback is preserved (\x1b[2J only clears + // the viewport). + let _ = write_all(&stdout_fd, CLEAR_SCREEN); + let _ = write_all(&stdout_fd, CURSOR_HOME); + let _ = write_all(&stdout_fd, BRACKETED_PASTE_DISABLE); if let Some(ref termios) = self.original_termios { diff --git a/crates/claude-chill/src/screen_engine.rs b/crates/claude-chill/src/screen_engine.rs index 2302812..275007a 100644 --- a/crates/claude-chill/src/screen_engine.rs +++ b/crates/claude-chill/src/screen_engine.rs @@ -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) + ); + } } diff --git a/crates/claude-chill/src/test_support.rs b/crates/claude-chill/src/test_support.rs new file mode 100644 index 0000000..9ef515f --- /dev/null +++ b/crates/claude-chill/src/test_support.rs @@ -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 = 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 + ); +}