Compare commits

..

6 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
8ca1e80603 feat: replace vt100 with wezterm-term for the screen emulator
Some checks failed
CI / Version Badge (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / test-matrix (macos-latest) (pull_request) Has been cancelled
CI / test-matrix (ubuntu-latest) (pull_request) Has been cancelled
CI / Test (pull_request) Has been cancelled
CI / Integration Tests (macos-latest) (pull_request) Has been cancelled
CI / Integration Tests (ubuntu-latest) (pull_request) Has been cancelled
CI / Nix Build (macos-latest) (pull_request) Has been cancelled
CI / Nix Build (ubuntu-latest) (pull_request) Has been cancelled
Swap the rendering core. The previous backend (vt100 0.16) only stored
six attributes — fg/bg color, bold, dim, italic, underline, inverse —
and silently dropped everything else when re-emitting the screen. With
modern Claude Code that meant losing OSC 8 hyperlinks, strikethrough,
fancy underline styles, underline color, and so on.

New stack:
  * wezterm-term::Terminal handles the byte-driven VT emulation. Its
    cell model preserves the full modern attribute set including
    hyperlinks and underline color.
  * termwiz::surface::Surface is used as a "mirror of last emitted
    state." Each render diffs the terminal's current visible lines
    against this mirror to compute the minimum change set.
  * termwiz::render::TerminfoRenderer encodes those Changes as bytes
    using a TrueColor + hyperlink-capable capability profile.

A defensive OSC 8 close is appended to every render to handle the case
where the diff ends inside a hyperlinked region (the renderer only
emits the close on transition to a non-hyperlinked cell).

Both termwiz and wezterm-term are pulled from the wezterm git repo at
a pinned rev so the Line types unify across the dep graph.

Test results, against the color-preservation harness from the previous
commit (run via cargo test color_preservation -- --ignored on master):

  baseline (vt100):  0 / 5 passing
  after this commit: 3 / 5 passing  (osc8, strikethrough, double underline)

The two still-ignored tests (curly underline, underline color) are
upstream renderer limitations: termwiz's TerminfoRenderer only emits
Single/Double underline and never emits SGR 58. The cell model stores
both correctly; only the emit step drops them. Tests are kept #[ignore]
with comments calling out the upstream gap.

Total: 118 passed, 4 ignored, 0 failed.
2026-05-01 18:56:29 +00:00
1be6e4954e test: add failing color/SGR/hyperlink preservation tests
Pin the baseline: feed OSC 8 hyperlinks, strikethrough, curly underline,
underline color, and double underline through Proxy, force a render,
and capture what the proxy emits to its stdout side. Each test asserts
the styling survives the round-trip.

All five fail on the current vt100 0.16 backend because its cell model
only stores fg/bg color, bold, dim, italic, underline, inverse — every
other attribute is dropped at render time. Tests are #[ignore]-gated so
CI stays green; run with --ignored to see the baseline failures. Once
the emulator is swapped to termwiz, remove the gates.

A truecolor-foreground sanity test runs by default and confirms the
parts of the SGR space vt100 does support are still working.

Adds Proxy::force_render as a #[cfg(test)] wrapper around the private
render_vt_screen method.
2026-05-01 17:20:35 +00:00
3256167f48 test: add PTY integration test harness
Add a pty-mock workspace crate (echo/alt-screen/paste-echo/buffer-fill/
sync-blocks subcommands) plus an in-process integration test suite that
drives Proxy directly via a new_for_test constructor and #[cfg(test)]
state accessors. Sets up the regression net needed before swapping the
underlying VT emulator from vt100 to termwiz.

Pulled from upstream draft PR #35; the alt-screen feed-order semantic
change is intentionally deferred (its dedicated test is left #[ignore]).
2026-05-01 17:11:08 +00:00
14 changed files with 2968 additions and 135 deletions

View File

@@ -63,6 +63,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- run: cargo build -p pty-mock
- run: cargo test --all-targets - run: cargo test --all-targets
test: test:

3
.gitignore vendored
View File

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

1322
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"crates/claude-chill", "crates/claude-chill",
"crates/pty-mock",
] ]
[workspace.package] [workspace.package]

View File

@@ -25,7 +25,7 @@ nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"]
libc = "0.2" libc = "0.2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
vt100 = "0.16" termwiz = { git = "https://github.com/wezterm/wezterm", rev = "577474d89ee61aef4a48145cdec82a638d874751" }
termwiz = "0.23" wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "577474d89ee61aef4a48145cdec82a638d874751" }
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"

View File

@@ -0,0 +1,203 @@
//! Color / SGR / hyperlink preservation tests.
//!
//! These tests feed modern terminal styling bytes through a real `Proxy`,
//! force a render, capture what the proxy emits to its stdout side, and
//! assert that the styling survives the round-trip.
//!
//! After the swap from `vt100 0.16` to `wezterm-term` + `termwiz` Surface
//! diffing, most of the modern attribute set is preserved end-to-end:
//! OSC 8 hyperlinks, strikethrough, double underline, and truecolor all
//! round-trip. Two known gaps remain — both are limitations of termwiz's
//! `TerminfoRenderer`, not of the cell model:
//!
//! * Curly / dotted / dashed underline styles (`SGR 4:3`, `4:4`, `4:5`):
//! stored on the cell, never emitted on render.
//! * Underline color (`SGR 58`): stored on the cell, never emitted on
//! render.
//!
//! Tests for those two are kept `#[ignore]` so CI stays green. They will
//! pass if/when termwiz's renderer learns to emit them (or we replace
//! the emit step with a custom serializer).
#[cfg(test)]
mod tests {
use crate::proxy::{Proxy, ProxyConfig};
use nix::fcntl::{FcntlArg, OFlag, fcntl};
use nix::pty::openpty;
use nix::sys::termios::{self, SetArg};
use nix::unistd::{pipe, read};
use std::os::fd::{AsFd, AsRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command};
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();
let mut termios_settings = termios::tcgetattr(&pty.slave).expect("tcgetattr failed");
termios::cfmakeraw(&mut termios_settings);
termios::tcsetattr(&pty.slave, SetArg::TCSANOW, &termios_settings)
.expect("tcsetattr failed");
let child = unsafe {
Command::new(&bin)
.arg("echo")
.stdin(pty.slave.try_clone().expect("clone slave"))
.stdout(pty.slave.try_clone().expect("clone slave"))
.stderr(pty.slave.try_clone().expect("clone slave"))
.pre_exec(move || {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if libc::ioctl(slave_fd, libc::TIOCSCTTY as libc::c_ulong, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
})
.spawn()
.expect("failed to spawn pty-mock echo")
};
drop(pty.slave);
let flags = fcntl(&pty.master, FcntlArg::F_GETFL).expect("F_GETFL");
let flags = OFlag::from_bits_truncate(flags);
fcntl(&pty.master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL");
(pty.master, child)
}
fn make_proxy(master: OwnedFd, child: Child) -> Proxy {
Proxy::new_for_test(master, child, ProxyConfig::default(), 24, 80)
}
/// Make a non-blocking pipe to capture proxy output.
fn capture_pipe() -> (OwnedFd, OwnedFd) {
let (rx, tx) = pipe().expect("pipe failed");
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");
(rx, tx)
}
fn drain(rx: &OwnedFd) -> Vec<u8> {
let mut buf = [0u8; 65536];
let mut out = Vec::new();
loop {
match read(rx.as_fd(), &mut buf) {
Ok(0) => break,
Ok(n) => out.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
out
}
/// Feed `input` through the proxy, force a render to a captured pipe,
/// and return what the proxy emitted.
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");
drain(&rx)
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
#[test]
fn osc8_hyperlink_survives_render() {
// OSC 8 hyperlinks — used for clickable file paths / URLs.
// Format: ESC ] 8 ; ; URL ESC \ ... ESC ] 8 ; ; ESC \
let mut input = Vec::new();
input.extend_from_slice(b"\x1b]8;;https://example.com/foo\x1b\\");
input.extend_from_slice(b"link text");
input.extend_from_slice(b"\x1b]8;;\x1b\\");
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: {:?}",
String::from_utf8_lossy(&captured)
);
assert!(
contains_bytes(&captured, b"\x1b]8;;\x1b\\"),
"OSC 8 hyperlink close should survive render, got: {:?}",
String::from_utf8_lossy(&captured)
);
}
#[test]
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);
assert!(
contains_bytes(&captured, b"\x1b[9m") || contains_bytes(&captured, b";9m"),
"SGR 9 strikethrough should survive render, got: {:?}",
String::from_utf8_lossy(&captured)
);
}
#[test]
#[ignore] // termwiz TerminfoRenderer only emits Single/Double underline.
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);
assert!(
contains_bytes(&captured, b"4:3"),
"SGR 4:3 curly underline should survive render, got: {:?}",
String::from_utf8_lossy(&captured)
);
}
#[test]
#[ignore] // termwiz TerminfoRenderer doesn't emit SGR 58 underline color.
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);
assert!(
contains_bytes(&captured, b"58:2") || contains_bytes(&captured, b"58;2"),
"SGR 58 underline color should survive render, got: {:?}",
String::from_utf8_lossy(&captured)
);
}
#[test]
fn double_underline_survives_render() {
// SGR 21 = doubly underlined.
let input = b"\x1b[21mdouble underlined\x1b[0m";
let captured = render_and_capture(input);
assert!(
contains_bytes(&captured, b"\x1b[21m")
|| contains_bytes(&captured, b";21m")
|| contains_bytes(&captured, b"4:2"),
"SGR 21 double underline should survive render, got: {:?}",
String::from_utf8_lossy(&captured)
);
}
/// Sanity check: confirm a styling attribute that vt100 *does* support
/// already survives the render. If this ever fails, something more
/// fundamental is broken than just attribute coverage.
#[test]
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 captured = render_and_capture(input);
assert!(
contains_bytes(&captured, b"38;2;255;128;0")
|| contains_bytes(&captured, b"38:2::255:128:0"),
"truecolor foreground should survive render (sanity), got: {:?}",
String::from_utf8_lossy(&captured)
);
}
}

View File

@@ -0,0 +1,749 @@
#[cfg(test)]
mod tests {
use crate::proxy::{Proxy, ProxyConfig};
use nix::fcntl::{FcntlArg, OFlag, fcntl};
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use nix::pty::openpty;
use nix::sys::termios::{self, SetArg};
use nix::unistd::read;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command};
use std::time::Duration;
fn pty_mock_binary() -> String {
crate::test_support::pty_mock_binary()
}
fn spawn_command(cmd: &str, args: &[&str]) -> (OwnedFd, Child) {
let pty = openpty(None, None).expect("openpty failed");
let slave_fd = pty.slave.as_raw_fd();
let mut termios_settings = termios::tcgetattr(&pty.slave).expect("tcgetattr failed");
termios::cfmakeraw(&mut termios_settings);
termios::tcsetattr(&pty.slave, SetArg::TCSANOW, &termios_settings)
.expect("tcsetattr failed");
let child = unsafe {
Command::new(cmd)
.args(args)
.stdin(pty.slave.try_clone().expect("clone slave"))
.stdout(pty.slave.try_clone().expect("clone slave"))
.stderr(pty.slave.try_clone().expect("clone slave"))
.pre_exec(move || {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if libc::ioctl(slave_fd, libc::TIOCSCTTY as libc::c_ulong, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
})
.spawn()
.expect("failed to spawn command")
};
drop(pty.slave);
// Set master to non-blocking
let flags = fcntl(&pty.master, FcntlArg::F_GETFL).expect("F_GETFL");
let flags = OFlag::from_bits_truncate(flags);
fcntl(&pty.master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL");
(pty.master, child)
}
fn spawn_mock(subcommand: &str) -> (OwnedFd, Child) {
spawn_command(&pty_mock_binary(), &[subcommand])
}
fn read_pty_output(master: &OwnedFd, timeout_ms: u16) -> Vec<u8> {
let mut result = Vec::new();
let mut buf = [0u8; 65536];
let mut poll_fds = [PollFd::new(master.as_fd(), PollFlags::POLLIN)];
loop {
match poll(&mut poll_fds, PollTimeout::from(timeout_ms)) {
Ok(0) => break,
Ok(_) => match read(master.as_fd(), &mut buf) {
Ok(0) => break,
Ok(n) => result.extend_from_slice(&buf[..n]),
Err(nix::errno::Errno::EAGAIN) => break,
Err(_) => break,
},
Err(_) => break,
}
}
result
}
fn make_proxy(master: OwnedFd, child: Child) -> Proxy {
let config = ProxyConfig::default();
Proxy::new_for_test(master, child, config, 24, 80)
}
fn dev_null() -> OwnedFd {
use std::fs::OpenOptions;
let f = OpenOptions::new()
.write(true)
.open("/dev/null")
.expect("open /dev/null");
OwnedFd::from(f)
}
fn dup_master(proxy: &Proxy) -> OwnedFd {
let raw = proxy.pty_master_fd_raw();
unsafe { OwnedFd::from_raw_fd(libc::dup(raw)) }
}
#[test]
fn test_echo_baseline() {
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
proxy
.process_input(b"hello world\r\n", &sink)
.expect("process_input");
proxy.flush_drain_buffer(&sink).expect("flush drain");
std::thread::sleep(Duration::from_millis(50));
let master_dup = dup_master(&proxy);
let output = read_pty_output(&master_dup, 100);
assert!(!output.is_empty(), "expected output from echo child");
proxy
.process_output(&output, &sink)
.expect("process_output");
let mut history = Vec::new();
proxy.history().append_all(&mut history);
let history_str = String::from_utf8_lossy(&history);
assert!(
history_str.contains("hello world"),
"history should contain echoed text, got: {:?}",
history_str
);
}
#[test]
fn test_alt_screen_tracking() {
let (master, child) = spawn_mock("alt-screen");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
std::thread::sleep(Duration::from_millis(50));
let master_dup = dup_master(&proxy);
let output = read_pty_output(&master_dup, 100);
if !output.is_empty() {
proxy
.process_output(&output, &sink)
.expect("process_output");
}
assert!(
proxy.is_in_alternate_screen(),
"proxy should be in alt screen"
);
proxy.process_input(b"x", &sink).expect("process_input");
proxy.flush_drain_buffer(&sink).expect("flush drain");
std::thread::sleep(Duration::from_millis(50));
let master_dup = dup_master(&proxy);
let output = read_pty_output(&master_dup, 100);
if !output.is_empty() {
proxy
.process_output(&output, &sink)
.expect("process_output");
}
assert!(
!proxy.is_in_alternate_screen(),
"proxy should have exited alt screen"
);
}
#[test]
fn test_bracketed_paste_flow() {
let (master, child) = spawn_mock("paste-echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
std::thread::sleep(Duration::from_millis(50));
let master_dup = dup_master(&proxy);
let output = read_pty_output(&master_dup, 100);
if !output.is_empty() {
proxy
.process_output(&output, &sink)
.expect("process_output");
}
let mut paste = Vec::new();
paste.extend_from_slice(b"\x1b[200~");
paste.extend_from_slice(b"pasted content\r\n");
paste.extend_from_slice(b"\x1b[201~");
proxy.process_input(&paste, &sink).expect("process_input");
proxy.flush_drain_buffer(&sink).expect("flush drain");
assert!(
!proxy.is_in_bracketed_paste(),
"paste mode should be off after complete paste"
);
}
#[test]
fn test_split_paste_end_marker() {
let (master, child) = spawn_mock("paste-echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
std::thread::sleep(Duration::from_millis(50));
let master_dup = dup_master(&proxy);
let output = read_pty_output(&master_dup, 100);
if !output.is_empty() {
proxy
.process_output(&output, &sink)
.expect("process_output");
}
let mut chunk1 = Vec::new();
chunk1.extend_from_slice(b"\x1b[200~");
chunk1.extend_from_slice(b"split test\r\n");
chunk1.extend_from_slice(b"\x1b[20"); // partial end marker
proxy
.process_input(&chunk1, &sink)
.expect("process_input chunk1");
proxy.flush_drain_buffer(&sink).expect("flush drain");
assert!(
proxy.is_in_bracketed_paste(),
"should still be in paste mode after partial end marker"
);
proxy
.process_input(b"1~", &sink)
.expect("process_input chunk2");
proxy.flush_drain_buffer(&sink).expect("flush drain");
assert!(
!proxy.is_in_bracketed_paste(),
"paste mode should be off after completing split end marker"
);
}
#[test]
fn test_alt_screen_exit_restores_main_content() {
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
// Simulate child writing main screen content
let main_content = b"Claude Code v2.1.84\r\nOpus 4.6\r\n~/public/claude-chill\r\n";
proxy
.process_output(main_content, &sink)
.expect("process_output main content");
let screen_before = proxy.vt_screen_text();
assert!(
screen_before.contains("Claude Code v2.1.84"),
"main content should be on screen before alt screen, got: {:?}",
screen_before
);
// Child enters alt screen
proxy
.process_output(b"\x1b[?1049h", &sink)
.expect("process_output alt enter");
assert!(proxy.is_in_alternate_screen());
// Child writes alt screen content (e.g. vim, less)
proxy
.process_output(b"alt screen editor content\r\nline 2\r\n", &sink)
.expect("process_output alt content");
// Child exits alt screen
proxy
.process_output(b"\x1b[?1049l", &sink)
.expect("process_output alt exit");
assert!(!proxy.is_in_alternate_screen());
// The VT parser should have restored the main screen content
let screen_after = proxy.vt_screen_text();
assert!(
screen_after.contains("Claude Code v2.1.84"),
"main content should be restored after alt screen exit, got: {:?}",
screen_after
);
}
// Skipped on this branch: depends on the alt-screen feed-order fix from
// upstream PR #35 (incremental VT feeding around alt enter/exit). We're
// pulling in only the test harness now; the semantic fix is a separate
// step. Re-enable by removing #[ignore] once that change lands.
#[test]
#[ignore]
fn test_alt_screen_exit_restores_after_clear_screen_sync_block() {
// Reproduces bug: sync block containing clear screen clears VT parser,
// but render_vt_screen never fires before alt-screen-enter arrives
// 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 (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
// Initial content via sync block — this gets rendered
let mut initial_sync = Vec::new();
initial_sync.extend_from_slice(b"\x1b[?2026h");
initial_sync.extend_from_slice(b"\x1b[H");
initial_sync.extend_from_slice(b"Claude Code v2.1.84\r\n");
initial_sync.extend_from_slice(b"Opus 4.6\r\n");
initial_sync.extend_from_slice(b"~/public/claude-chill\r\n");
initial_sync.extend_from_slice(b"\x1b[?2026l");
proxy
.process_output(&initial_sync, &sink)
.expect("initial sync block");
let screen_before = proxy.vt_screen_text();
assert!(
screen_before.contains("Claude Code v2.1.84"),
"header should be on screen initially"
);
// Sync block with clear screen (simulates ctrl-g response) —
// VT parser processes the clear but render never fires
let mut clear_sync = Vec::new();
clear_sync.extend_from_slice(b"\x1b[?2026h");
clear_sync.extend_from_slice(b"\x1b[2J"); // clear screen
clear_sync.extend_from_slice(b"\x1b[H");
clear_sync.extend_from_slice(b"\x1b[?2026l");
proxy
.process_output(&clear_sync, &sink)
.expect("clear sync block");
// VT parser screen is now blank (clear screen was processed)
let screen_after_clear = proxy.vt_screen_text();
assert!(
!screen_after_clear.contains("Claude Code"),
"VT screen should be blank after clear sync block"
);
// Alt screen enter in the NEXT chunk — no render_vt_screen fired
// between the clear sync and this. VT parser saves blank screen.
proxy
.process_output(b"\x1b[?1049h", &sink)
.expect("alt screen enter");
assert!(proxy.is_in_alternate_screen());
// Editor content during alt screen
proxy
.process_output(b"editor content\r\n", &sink)
.expect("alt content");
// Alt screen exit — VT restores saved (blank) screen
proxy
.process_output(b"\x1b[?1049l", &sink)
.expect("alt screen exit");
assert!(!proxy.is_in_alternate_screen());
// Clear screen wiped the main buffer before alt-screen saved it.
// Correct VT100 behavior: restored screen should be blank.
let screen_after_exit = proxy.vt_screen_text();
assert!(
!screen_after_exit.contains("Claude Code v2.1.84"),
"header should NOT survive clear + alt screen round-trip, got: {:?}",
screen_after_exit
);
}
#[test]
fn test_sync_block_tracking() {
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
// Start a sync block without ending it
proxy
.process_output(b"\x1b[?2026h some content", &sink)
.expect("partial sync block");
assert!(
proxy.is_in_sync_block(),
"should be in sync block after SYNC_START without SYNC_END"
);
// End the sync block
proxy
.process_output(b" more content \x1b[?2026l", &sink)
.expect("sync block end");
assert!(
!proxy.is_in_sync_block(),
"should not be in sync block after SYNC_END"
);
}
#[test]
fn test_lookback_mode_toggle() {
let (master, child) = spawn_mock("echo");
let mut proxy = make_proxy(master, child);
let sink = dev_null();
// Generate some output so lookback has content
proxy
.process_output(b"line 1\r\nline 2\r\nline 3\r\n", &sink)
.expect("output");
assert!(!proxy.is_in_lookback_mode());
// Send legacy lookback key (0x1E = Ctrl+^)
send_input(&mut proxy, &sink, &[0x1E]);
assert!(
proxy.is_in_lookback_mode(),
"should enter lookback mode after Ctrl+^"
);
// Send it again to exit
send_input(&mut proxy, &sink, &[0x1E]);
assert!(
!proxy.is_in_lookback_mode(),
"should exit lookback mode after second Ctrl+^"
);
}
/// 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);
if !output.is_empty() {
proxy.process_output(&output, sink).expect("process_output");
}
}
fn send_input(proxy: &mut Proxy, sink: &OwnedFd, data: &[u8]) {
proxy.process_input(data, sink).expect("process_input");
proxy.flush_drain_buffer(sink).expect("flush drain");
}
fn wait_for_screen(proxy: &mut Proxy, sink: &OwnedFd, text: &str, timeout: Duration) -> bool {
let start = std::time::Instant::now();
while start.elapsed() < timeout {
pump_proxy(proxy, sink, 100);
if proxy.vt_screen_text().contains(text) {
return true;
}
}
false
}
#[test]
#[ignore] // requires claude CLI to be installed and authenticated
fn test_claude_alt_screen_restore() {
let (master, child) = spawn_command("claude", &[]);
let mut proxy = make_proxy(master, child);
let sink = dev_null();
// Wait for Claude Code to start up and show its UI
assert!(
wait_for_screen(&mut proxy, &sink, "Claude Code", Duration::from_secs(10)),
"Claude Code did not start, screen: {:?}",
proxy.vt_screen_text()
);
let screen_before = proxy.vt_screen_text();
eprintln!("Screen before alt screen:\n{}", screen_before);
assert!(
screen_before.contains("▐▛███▜▌"),
"logo should be present before alt screen"
);
// Send Ctrl-G to open the editor (enters alt screen)
send_input(&mut proxy, &sink, &[0x07]); // Ctrl-G
// Wait for alt screen to be entered
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(5) {
pump_proxy(&mut proxy, &sink, 100);
if proxy.is_in_alternate_screen() {
break;
}
}
assert!(
proxy.is_in_alternate_screen(),
"should have entered alt screen after Ctrl-G"
);
// Pump until editor has rendered
pump_proxy(&mut proxy, &sink, 500);
// Send :wq<Enter> to close the editor (exits alt screen)
send_input(&mut proxy, &sink, b"\x1b");
pump_proxy(&mut proxy, &sink, 100);
send_input(&mut proxy, &sink, b":wq\r");
// Wait for alt screen to be exited
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(5) {
pump_proxy(&mut proxy, &sink, 100);
if !proxy.is_in_alternate_screen() {
break;
}
}
// Drain remaining output after alt screen exit
pump_proxy(&mut proxy, &sink, 500);
let vt_screen_after = proxy.vt_screen_text();
eprintln!("VT parser screen after alt exit:\n{}", vt_screen_after);
// Claude Code bug: after editor exit, the logo (▐▛███▜▌) is not
// redrawn. This happens with or without claude-chill.
// Update this assertion if Claude Code fixes the redraw.
assert!(
!vt_screen_after.contains("▐▛███▜▌"),
"logo should NOT appear after alt screen exit (known Claude Code bug), got: {:?}",
vt_screen_after
);
// Send /exit to quit
send_input(&mut proxy, &sink, b"/exit\r");
pump_proxy(&mut proxy, &sink, 1000);
}
}

View File

@@ -1,8 +1,15 @@
#[cfg(test)]
mod color_preservation_tests;
pub mod config; pub mod config;
pub mod escape_filter; pub mod escape_filter;
pub mod escape_sequences; pub mod escape_sequences;
pub mod history_filter; pub mod history_filter;
#[cfg(test)]
mod integration_tests;
pub mod key_parser; pub mod key_parser;
pub mod line_buffer; pub mod line_buffer;
pub mod proxy; pub mod proxy;
pub mod redraw_throttler; pub mod redraw_throttler;
pub mod screen_engine;
#[cfg(test)]
mod test_support;

View File

@@ -6,6 +6,7 @@ use crate::escape_sequences::{
}; };
use crate::history_filter::HistoryFilter; use crate::history_filter::HistoryFilter;
use crate::line_buffer::LineBuffer; use crate::line_buffer::LineBuffer;
use crate::screen_engine::ScreenEngine;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::debug; use log::debug;
use memchr::memmem; use memchr::memmem;
@@ -92,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; const SYNC_BLOCK_DELAY_MS: u64 = 50;
pub struct Proxy { pub struct Proxy {
@@ -102,8 +103,7 @@ pub struct Proxy {
original_termios: Option<Termios>, original_termios: Option<Termios>,
history: LineBuffer, history: LineBuffer,
history_filter: HistoryFilter, history_filter: HistoryFilter,
vt_parser: vt100::Parser, screen: ScreenEngine,
vt_prev_screen: Option<vt100::Screen>,
last_output_time: Option<Instant>, last_output_time: Option<Instant>,
last_render_time: Option<Instant>, last_render_time: Option<Instant>,
last_stdin_time: Option<Instant>, last_stdin_time: Option<Instant>,
@@ -133,6 +133,15 @@ pub struct Proxy {
paste_end_finder: memmem::Finder<'static>, paste_end_finder: memmem::Finder<'static>,
pty_drain_buffer: Vec<u8>, pty_drain_buffer: Vec<u8>,
paste_remainder: Vec<u8>, paste_remainder: Vec<u8>,
/// True when running under tmux ($TMUX is set). In this mode we forward
/// child output bytes directly to the terminal instead of differentially
/// re-rendering through wezterm-term + termwiz, because tmux is also
/// tracking the cell grid and termwiz's `Surface::diff_lines` doesn't
/// emit clearing Changes for cells that became blank — the resulting
/// stale-cell drift turns into a palimpsest in tmux's view of the pane.
/// Lookback (history dump + one-shot full repaint on exit) still uses
/// the rendered path; only steady-state output goes raw.
passthrough_mode: bool,
} }
/// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode /// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode
@@ -270,7 +279,14 @@ impl Proxy {
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?; write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0); // 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 // Seed history with clear screen so replay starts fresh
let mut history = LineBuffer::new(config.max_history_lines); let mut history = LineBuffer::new(config.max_history_lines);
@@ -279,6 +295,11 @@ impl Proxy {
let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms); let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms);
let passthrough_mode = std::env::var_os("TMUX").is_some();
if passthrough_mode {
debug!("Proxy::spawn: $TMUX detected, enabling passthrough render mode");
}
debug!("Proxy::spawn: command={} args={:?}", command, args); debug!("Proxy::spawn: command={} args={:?}", command, args);
Ok(Self { Ok(Self {
@@ -288,8 +309,7 @@ impl Proxy {
pty_master: pty.master, pty_master: pty.master,
child, child,
original_termios: terminal_guard.take(), original_termios: terminal_guard.take(),
vt_parser, screen,
vt_prev_screen: None,
last_output_time: None, last_output_time: None,
last_render_time: None, last_render_time: None,
last_stdin_time: None, last_stdin_time: None,
@@ -319,9 +339,124 @@ impl Proxy {
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END), paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
pty_drain_buffer: Vec::new(), pty_drain_buffer: Vec::new(),
paste_remainder: Vec::new(), paste_remainder: Vec::new(),
passthrough_mode,
}) })
} }
#[cfg(test)]
pub(crate) fn new_for_test(
pty_master: OwnedFd,
child: Child,
config: ProxyConfig,
rows: u16,
cols: u16,
) -> Self {
let screen = ScreenEngine::new(rows, cols);
let mut history = LineBuffer::new(config.max_history_lines);
history.push_bytes(CLEAR_SCREEN);
history.push_bytes(CURSOR_HOME);
let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms);
Self {
history,
history_filter: HistoryFilter::new(),
config,
pty_master,
child,
original_termios: None,
screen,
last_output_time: None,
last_render_time: None,
last_stdin_time: None,
last_auto_lookback_time: None,
auto_lookback_timeout,
sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY),
in_sync_block: false,
in_lookback_mode: false,
in_alternate_screen: false,
in_bracketed_paste: false,
kitty_mode_supported: false,
kitty_mode_stack: 0,
kitty_output_parser: TermwizParser::new(),
vt_render_pending: false,
lookback_cache: Vec::new(),
lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY),
sync_start_finder: memmem::Finder::new(SYNC_START),
sync_end_finder: memmem::Finder::new(SYNC_END),
clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN),
cursor_home_finder: memmem::Finder::new(CURSOR_HOME),
alt_screen_enter_finder: memmem::Finder::new(ALT_SCREEN_ENTER),
alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT),
alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY),
alt_screen_exit_legacy_finder: memmem::Finder::new(ALT_SCREEN_EXIT_LEGACY),
paste_start_finder: memmem::Finder::new(BRACKETED_PASTE_START),
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
pty_drain_buffer: Vec::new(),
paste_remainder: Vec::new(),
passthrough_mode: false,
}
}
#[cfg(test)]
pub(crate) fn history(&self) -> &LineBuffer {
&self.history
}
#[cfg(test)]
pub(crate) fn is_in_alternate_screen(&self) -> bool {
self.in_alternate_screen
}
#[cfg(test)]
pub(crate) fn is_in_bracketed_paste(&self) -> bool {
self.in_bracketed_paste
}
#[cfg(test)]
pub(crate) fn is_in_sync_block(&self) -> bool {
self.in_sync_block
}
#[cfg(test)]
pub(crate) fn is_in_lookback_mode(&self) -> bool {
self.in_lookback_mode
}
#[cfg(test)]
pub(crate) fn vt_screen_text(&mut self) -> String {
self.screen.screen_text()
}
#[cfg(test)]
pub(crate) fn pty_master_fd_raw(&self) -> i32 {
self.pty_master.as_raw_fd()
}
#[cfg(test)]
pub(crate) fn force_render<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
self.render_vt_screen(stdout_fd)
}
#[cfg(test)]
pub(crate) fn flush_drain_buffer<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
if !self.pty_drain_buffer.is_empty() {
let drained = std::mem::take(&mut self.pty_drain_buffer);
self.process_output(&drained, stdout_fd)?;
}
Ok(())
}
#[cfg(test)]
pub(crate) fn set_passthrough_mode(&mut self, on: bool) {
self.passthrough_mode = on;
}
#[cfg(test)]
pub(crate) fn vt_render_pending(&self) -> bool {
self.vt_render_pending
}
pub fn run(&mut self) -> Result<i32> { pub fn run(&mut self) -> Result<i32> {
let stdin_fd = io::stdin(); let stdin_fd = io::stdin();
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
@@ -406,7 +541,7 @@ impl Proxy {
self.wait_child() self.wait_child()
} }
fn process_output<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { pub(crate) fn process_output<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
self.process_output_inner(data, stdout_fd, true) self.process_output_inner(data, stdout_fd, true)
} }
@@ -428,7 +563,7 @@ impl Proxy {
// Feed VT but NOT history while in alt screen // Feed VT but NOT history while in alt screen
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history // Alt screen content (TUI editors, etc.) shouldn't be in lookback history
if feed_vt { if feed_vt {
self.vt_parser.process(data); self.screen.process(data);
} }
return self.process_output_alt_screen(data, stdout_fd); return self.process_output_alt_screen(data, stdout_fd);
} }
@@ -441,9 +576,18 @@ impl Proxy {
// Feed data to VT emulator (unless already fed by caller) // Feed data to VT emulator (unless already fed by caller)
if feed_vt { if feed_vt {
self.vt_parser.process(data); self.screen.process(data);
} }
if self.passthrough_mode {
// Under tmux: forward bytes raw to the real terminal instead of
// queueing a diff render. tmux is the smart emulator and the
// termwiz diff path produces palimpsest artifacts in its grid.
// The wezterm-term grid is still kept current above so lookback
// / one-shot full repaints work.
self.write_to_terminal(stdout_fd, data)?;
} else {
self.vt_render_pending = true; self.vt_render_pending = true;
}
self.last_output_time = Some(Instant::now()); self.last_output_time = Some(Instant::now());
// Process sync blocks for history management // Process sync blocks for history management
@@ -517,7 +661,7 @@ impl Proxy {
// Force full VT render to restore main screen content // Force full VT render to restore main screen content
debug!("process_output_alt_screen: rendering VT screen after alt exit"); debug!("process_output_alt_screen: rendering VT screen after alt exit");
self.vt_prev_screen = None; self.screen.force_full_next();
self.render_vt_screen(stdout_fd)?; self.render_vt_screen(stdout_fd)?;
// Data after ALT_EXIT was already fed to VT and history when we processed // Data after ALT_EXIT was already fed to VT and history when we processed
@@ -724,32 +868,14 @@ impl Proxy {
} }
fn render_vt_screen<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> { fn render_vt_screen<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
let is_diff = self.vt_prev_screen.is_some(); let body = self.screen.render()?;
self.output_buffer.clear(); self.output_buffer.clear();
self.output_buffer.extend_from_slice(SYNC_START); self.output_buffer.extend_from_slice(SYNC_START);
self.output_buffer.extend_from_slice(&body);
match &self.vt_prev_screen {
Some(prev) => {
// Diff-based render: only send changes
self.output_buffer
.extend_from_slice(&self.vt_parser.screen().contents_diff(prev));
}
None => {
// First render: full screen
self.output_buffer
.extend_from_slice(&self.vt_parser.screen().contents_formatted());
}
}
self.output_buffer
.extend_from_slice(&self.vt_parser.screen().cursor_state_formatted());
self.output_buffer.extend_from_slice(SYNC_END); self.output_buffer.extend_from_slice(SYNC_END);
debug!( debug!("render_vt_screen: output_len={}", self.output_buffer.len());
"render_vt_screen: diff={} output_len={}\n",
is_diff,
self.output_buffer.len()
);
// Can't use write_to_terminal here due to borrow checker - can't pass // Can't use write_to_terminal here due to borrow checker - can't pass
// &self.output_buffer while also taking &mut self // &self.output_buffer while also taking &mut self
Self::update_kitty_mode_helper( Self::update_kitty_mode_helper(
@@ -760,8 +886,6 @@ impl Proxy {
); );
write_all(stdout_fd, &self.output_buffer)?; write_all(stdout_fd, &self.output_buffer)?;
// Store current screen for next diff
self.vt_prev_screen = Some(self.vt_parser.screen().clone());
self.vt_render_pending = false; self.vt_render_pending = false;
self.last_render_time = Some(Instant::now()); self.last_render_time = Some(Instant::now());
Ok(()) Ok(())
@@ -838,11 +962,11 @@ impl Proxy {
write_all(stdout_fd, &self.output_buffer)?; write_all(stdout_fd, &self.output_buffer)?;
// Force full VT render on next output since terminal now shows history // Force full VT render on next output since terminal now shows history
self.vt_prev_screen = None; self.screen.force_full_next();
Ok(()) Ok(())
} }
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { pub(crate) fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
self.last_stdin_time = Some(Instant::now()); self.last_stdin_time = Some(Instant::now());
debug!( debug!(
@@ -1058,9 +1182,14 @@ impl Proxy {
self.forward_winsize()?; self.forward_winsize()?;
// Clear the lookback dump out of the visible pane so blank cells in
// the new VT state don't leak old history through.
self.write_to_terminal(stdout_fd, CLEAR_SCREEN)?;
self.write_to_terminal(stdout_fd, CURSOR_HOME)?;
// Force full render since terminal was showing history // Force full render since terminal was showing history
debug!("exit_lookback_mode: rendering VT screen"); debug!("exit_lookback_mode: rendering VT screen");
self.vt_prev_screen = None; self.screen.force_full_next();
self.render_vt_screen(stdout_fd)?; self.render_vt_screen(stdout_fd)?;
Ok(()) Ok(())
@@ -1073,11 +1202,7 @@ impl Proxy {
winsize.ws_row, winsize.ws_col winsize.ws_row, winsize.ws_col
); );
// Resize VT emulator // Resize VT emulator
self.vt_parser self.screen.resize(winsize.ws_row, winsize.ws_col);
.screen_mut()
.set_size(winsize.ws_row, winsize.ws_col);
// Force full render on next frame since size changed
self.vt_prev_screen = None;
// Forward to child process // Forward to child process
unsafe { unsafe {
libc::ioctl( libc::ioctl(
@@ -1105,8 +1230,16 @@ impl Proxy {
impl Drop for Proxy { impl Drop for Proxy {
fn drop(&mut self) { fn drop(&mut self) {
// Disable bracketed paste before restoring terminal
let stdout_fd = io::stdout(); let stdout_fd = io::stdout();
// Reset any attribute state Claude (or claude-chill's renderer)
// may have left active so it doesn't leak into the shell prompt.
// Don't wipe the screen — once the palimpsest bug is fixed, the
// child's final output (e.g. Claude's "Bye!") is fine to leave on
// screen, matching how non-TUI CLIs behave at exit.
let _ = write_all(&stdout_fd, b"\x1b]8;;\x1b\\");
let _ = write_all(&stdout_fd, b"\x1b[0m");
let _ = write_all(&stdout_fd, BRACKETED_PASTE_DISABLE); let _ = write_all(&stdout_fd, BRACKETED_PASTE_DISABLE);
if let Some(ref termios) = self.original_termios { if let Some(ref termios) = self.original_termios {

View File

@@ -0,0 +1,389 @@
//! Screen emulator backed by `wezterm-term`.
//!
//! This replaces the previous `vt100` backing. wezterm-term gives us full
//! modern attribute coverage — OSC 8 hyperlinks, strikethrough, fancy
//! underline styles, underline color — that vt100 0.16 dropped on render.
//!
//! Architecture:
//! - `wezterm_term::Terminal` is the byte-driven VT emulator. Feed bytes
//! via `advance_bytes`; query state via `screen().visible_lines()`.
//! - A `termwiz::surface::Surface` mirrors the last bytes we *emitted*
//! downstream; the next render diffs current visible lines against
//! this Surface to compute the minimum change set.
//! - `termwiz::render::TerminfoRenderer` serializes those Changes into
//! bytes using a TrueColor / hyperlink-capable capability set.
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;
impl TerminalConfiguration for ProxyTermConfig {
fn color_palette(&self) -> ColorPalette {
ColorPalette::default()
}
}
/// `Write + RenderTty` adapter that buffers everything in memory.
struct CapturedTty {
buf: Vec<u8>,
cols: usize,
rows: usize,
}
impl Write for CapturedTty {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl RenderTty for CapturedTty {
fn get_size_in_cells(&mut self) -> termwiz::Result<(usize, usize)> {
Ok((self.cols, self.rows))
}
}
pub struct ScreenEngine {
terminal: Terminal,
/// Mirror of what we've *already emitted* downstream. Diffing the
/// current terminal screen against this gives us the minimal change
/// set to send.
mirror: Surface,
renderer: TerminfoRenderer,
rows: usize,
cols: usize,
/// When set, the next render emits the full screen instead of a diff.
force_full: bool,
}
impl ScreenEngine {
pub fn new(rows: u16, cols: u16) -> Self {
let rows = rows as usize;
let cols = cols as usize;
let size = TerminalSize {
rows,
cols,
..Default::default()
};
let config: Arc<dyn TerminalConfiguration + Send + Sync> = Arc::new(ProxyTermConfig);
// We don't currently surface terminal responses (DA queries, cursor
// position reports, etc) back upstream. Matches the previous vt100
// behavior, which simply ignored them.
let writer: Box<dyn Write + Send> = Box::new(std::io::sink());
let terminal = Terminal::new(
size,
config,
"claude-chill",
env!("CARGO_PKG_VERSION"),
writer,
);
let mirror = Surface::new(cols, rows);
let renderer = make_renderer();
Self {
terminal,
mirror,
renderer,
rows,
cols,
force_full: true,
}
}
/// Feed bytes from the child pty into the emulator.
pub fn process(&mut self, data: &[u8]) {
self.terminal.advance_bytes(data);
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows as usize;
let cols = cols as usize;
self.terminal.resize(TerminalSize {
rows,
cols,
..Default::default()
});
self.mirror.resize(cols, rows);
self.rows = rows;
self.cols = cols;
self.force_full = true;
}
/// Mark the next `render()` call as a full screen redraw rather than a diff.
pub fn force_full_next(&mut self) {
self.force_full = true;
}
/// Build the change stream needed to bring downstream from `mirror` to
/// the current emulator state, encode it as bytes, and update `mirror`.
pub fn render(&mut self) -> Result<Vec<u8>> {
let lines = {
let screen = self.terminal.screen();
let rows = self.rows as i64;
let start = screen.phys_row(0);
let end = screen.phys_row(rows - 1) + 1;
screen.lines_in_phys_range(start..end)
};
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;
}
let mut changes = self.mirror.diff_lines(line_refs);
// Position the downstream cursor where the emulator's cursor is.
let cursor = self.terminal.cursor_pos();
let row = cursor.y.max(0) as usize;
let col = cursor.x;
changes.push(Change::CursorPosition {
x: Position::Absolute(col),
y: Position::Absolute(row),
});
// 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(),
cols: self.cols,
rows: self.rows,
};
self.renderer
.render_to(&changes, &mut tty)
.map_err(|e| anyhow::anyhow!("renderer: {}", e))?;
// 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)
}
/// Plain-text contents of the visible screen, for tests / lookback.
#[cfg(test)]
pub fn screen_text(&mut self) -> String {
let lines = {
let screen = self.terminal.screen();
let rows = self.rows as i64;
let start = screen.phys_row(0);
let end = screen.phys_row(rows - 1) + 1;
screen.lines_in_phys_range(start..end)
};
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&line.as_str());
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
fn render(input: &[u8]) -> Vec<u8> {
let mut e = ScreenEngine::new(24, 80);
e.process(input);
e.render().expect("render")
}
#[test]
fn osc8_hyperlink_round_trip() {
let mut input = Vec::new();
input.extend_from_slice(b"\x1b]8;;https://example.com\x1b\\");
input.extend_from_slice(b"link");
input.extend_from_slice(b"\x1b]8;;\x1b\\");
let out = render(&input);
assert!(
contains(&out, b"\x1b]8;;https://example.com"),
"OSC 8 missing in: {:?}",
String::from_utf8_lossy(&out)
);
}
#[test]
fn strikethrough_round_trip() {
let out = render(b"\x1b[9mstruck\x1b[0m");
assert!(
contains(&out, b"[9m") || contains(&out, b";9m"),
"SGR 9 missing in: {:?}",
String::from_utf8_lossy(&out)
);
}
#[test]
fn truecolor_round_trip() {
let out = render(b"\x1b[38;2;255;128;0morange\x1b[0m");
assert!(
contains(&out, b"38:2::255:128:0")
|| contains(&out, b"38;2;255;128;0")
|| contains(&out, b"38:2:255:128:0"),
"truecolor fg missing in: {:?}",
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
);
}

View File

@@ -0,0 +1,12 @@
[package]
name = "pty-mock"
version.workspace = true
edition.workspace = true
authors.workspace = true
[[bin]]
name = "pty-mock"
path = "src/bin.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }

146
crates/pty-mock/src/bin.rs Normal file
View File

@@ -0,0 +1,146 @@
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Read, Write};
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Echo,
AltScreen,
PasteEcho,
BufferFill,
SyncBlocks,
}
fn cmd_echo() {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if writeln!(stdout, "{}", line).is_err() {
break;
}
if stdout.flush().is_err() {
break;
}
}
}
fn cmd_alt_screen() {
let mut stdout = io::stdout();
let mut stdin = io::stdin();
// Enter alt screen
let _ = stdout.write_all(b"\x1b[?1049h");
let _ = stdout.flush();
// Write some content while in alt screen
let _ = stdout.write_all(b"alt screen content\r\n");
let _ = stdout.flush();
// Wait for any byte on stdin as trigger to exit
let mut trigger = [0u8; 1];
let _ = stdin.read_exact(&mut trigger);
// Exit alt screen
let _ = stdout.write_all(b"\x1b[?1049l");
let _ = stdout.flush();
// Write content after alt screen exit
let _ = stdout.write_all(b"back to normal\r\n");
let _ = stdout.flush();
}
fn cmd_paste_echo() {
let mut stdout = io::stdout();
let stdin = io::stdin();
// Enable bracketed paste mode
let _ = stdout.write_all(b"\x1b[?2004h");
let _ = stdout.flush();
// Read lines, prefix paste content
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if writeln!(stdout, "pasted: {}", line).is_err() {
break;
}
if stdout.flush().is_err() {
break;
}
}
}
fn cmd_buffer_fill() {
let mut stdout = io::stdout();
let stdin = io::stdin();
let mut stdin_lock = stdin.lock();
// Write large output to fill the PTY buffer
let chunk = "X".repeat(1024) + "\r\n";
for _ in 0..256 {
if stdout.write_all(chunk.as_bytes()).is_err() {
break;
}
}
let _ = stdout.flush();
// Now read stdin and echo it back
let mut buf = [0u8; 4096];
loop {
match stdin_lock.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if stdout.write_all(&buf[..n]).is_err() {
break;
}
if stdout.flush().is_err() {
break;
}
}
Err(_) => break,
}
}
}
fn cmd_sync_blocks() {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
// Wrap response in sync update block
let _ = stdout.write_all(b"\x1b[?2026h");
if writeln!(stdout, "sync: {}", line).is_err() {
break;
}
let _ = stdout.write_all(b"\x1b[?2026l");
if stdout.flush().is_err() {
break;
}
}
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Echo => cmd_echo(),
Commands::AltScreen => cmd_alt_screen(),
Commands::PasteEcho => cmd_paste_echo(),
Commands::BufferFill => cmd_buffer_fill(),
Commands::SyncBlocks => cmd_sync_blocks(),
}
}

View File

@@ -24,6 +24,7 @@
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
}; };
cargoBuildFlags = [ "-p" "claude-chill" ];
}; };
in in