Compare commits

..

4 Commits

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

View File

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

1322
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,216 @@
//! 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 pty_mock_binary() -> Option<String> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let bin = format!("{}/../../target/debug/pty-mock", manifest_dir);
if std::path::Path::new(&bin).exists() {
Some(bin)
} else {
None
}
}
fn spawn_echo_child() -> Option<(OwnedFd, Child)> {
let bin = pty_mock_binary()?;
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");
Some((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]) -> Option<Vec<u8>> {
let (master, child) = spawn_echo_child()?;
let mut proxy = make_proxy(master, child);
let (rx, tx) = capture_pipe();
proxy.process_output(input, &tx).expect("process_output");
proxy.force_render(&tx).expect("force_render");
Some(drain(&rx))
}
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).expect("pty-mock binary missing");
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).expect("pty-mock binary missing");
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).expect("pty-mock binary missing");
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).expect("pty-mock binary missing");
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).expect("pty-mock binary missing");
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 Some(captured) = render_and_capture(input) else {
// pty-mock not built — skip.
return;
};
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,539 @@
#[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() -> Option<String> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let bin = format!("{}/../../target/debug/pty-mock", manifest_dir);
if std::path::Path::new(&bin).exists() {
return Some(bin);
}
None
}
fn 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) -> Option<(OwnedFd, Child)> {
let bin = pty_mock_binary()?;
Some(spawn_command(&bin, &[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 Some((master, child)) = spawn_mock("echo") else {
return;
};
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 Some((master, child)) = spawn_mock("alt-screen") else {
return;
};
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 Some((master, child)) = spawn_mock("paste-echo") else {
return;
};
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 Some((master, child)) = spawn_mock("paste-echo") else {
return;
};
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 Some((master, child)) = spawn_mock("echo") else {
return;
};
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 Some((master, child)) = spawn_mock("echo") else {
return;
};
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 Some((master, child)) = spawn_mock("echo") else {
return;
};
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 Some((master, child)) = spawn_mock("echo") else {
return;
};
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+^"
);
}
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,13 @@
#[cfg(test)]
mod color_preservation_tests;
pub mod config;
pub mod escape_filter;
pub mod escape_sequences;
pub mod history_filter;
#[cfg(test)]
mod integration_tests;
pub mod key_parser;
pub mod line_buffer;
pub mod proxy;
pub mod redraw_throttler;
pub mod screen_engine;

View File

@@ -6,6 +6,7 @@ use crate::escape_sequences::{
};
use crate::history_filter::HistoryFilter;
use crate::line_buffer::LineBuffer;
use crate::screen_engine::ScreenEngine;
use anyhow::{Context, Result};
use log::debug;
use memchr::memmem;
@@ -102,8 +103,7 @@ pub struct Proxy {
original_termios: Option<Termios>,
history: LineBuffer,
history_filter: HistoryFilter,
vt_parser: vt100::Parser,
vt_prev_screen: Option<vt100::Screen>,
screen: ScreenEngine,
last_output_time: Option<Instant>,
last_render_time: Option<Instant>,
last_stdin_time: Option<Instant>,
@@ -270,7 +270,7 @@ impl Proxy {
let stdout_fd = io::stdout();
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0);
let screen = ScreenEngine::new(winsize.ws_row, winsize.ws_col);
// Seed history with clear screen so replay starts fresh
let mut history = LineBuffer::new(config.max_history_lines);
@@ -288,8 +288,7 @@ impl Proxy {
pty_master: pty.master,
child,
original_termios: terminal_guard.take(),
vt_parser,
vt_prev_screen: None,
screen,
last_output_time: None,
last_render_time: None,
last_stdin_time: None,
@@ -322,6 +321,109 @@ impl Proxy {
})
}
#[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(),
}
}
#[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(())
}
pub fn run(&mut self) -> Result<i32> {
let stdin_fd = io::stdin();
let stdout_fd = io::stdout();
@@ -406,7 +508,7 @@ impl Proxy {
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)
}
@@ -428,7 +530,7 @@ impl Proxy {
// Feed VT but NOT history while in alt screen
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history
if feed_vt {
self.vt_parser.process(data);
self.screen.process(data);
}
return self.process_output_alt_screen(data, stdout_fd);
}
@@ -441,7 +543,7 @@ impl Proxy {
// Feed data to VT emulator (unless already fed by caller)
if feed_vt {
self.vt_parser.process(data);
self.screen.process(data);
}
self.vt_render_pending = true;
self.last_output_time = Some(Instant::now());
@@ -517,7 +619,7 @@ impl Proxy {
// Force full VT render to restore main screen content
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)?;
// Data after ALT_EXIT was already fed to VT and history when we processed
@@ -724,32 +826,14 @@ impl Proxy {
}
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.extend_from_slice(SYNC_START);
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(&body);
self.output_buffer.extend_from_slice(SYNC_END);
debug!(
"render_vt_screen: diff={} output_len={}\n",
is_diff,
self.output_buffer.len()
);
debug!("render_vt_screen: output_len={}", self.output_buffer.len());
// Can't use write_to_terminal here due to borrow checker - can't pass
// &self.output_buffer while also taking &mut self
Self::update_kitty_mode_helper(
@@ -760,8 +844,6 @@ impl Proxy {
);
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.last_render_time = Some(Instant::now());
Ok(())
@@ -838,11 +920,11 @@ impl Proxy {
write_all(stdout_fd, &self.output_buffer)?;
// Force full VT render on next output since terminal now shows history
self.vt_prev_screen = None;
self.screen.force_full_next();
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());
debug!(
@@ -1060,7 +1142,7 @@ impl Proxy {
// Force full render since terminal was showing history
debug!("exit_lookback_mode: rendering VT screen");
self.vt_prev_screen = None;
self.screen.force_full_next();
self.render_vt_screen(stdout_fd)?;
Ok(())
@@ -1073,11 +1155,7 @@ impl Proxy {
winsize.ws_row, winsize.ws_col
);
// Resize VT emulator
self.vt_parser
.screen_mut()
.set_size(winsize.ws_row, winsize.ws_col);
// Force full render on next frame since size changed
self.vt_prev_screen = None;
self.screen.resize(winsize.ws_row, winsize.ws_col);
// Forward to child process
unsafe {
libc::ioctl(

View File

@@ -0,0 +1,264 @@
//! 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::render::RenderTty;
use termwiz::render::terminfo::TerminfoRenderer;
use termwiz::surface::{Change, Position, Surface};
use wezterm_term::color::ColorPalette;
use wezterm_term::{Terminal, TerminalConfiguration, TerminalSize};
#[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 caps = Capabilities::new_with_hints(
ProbeHints::default()
.color_level(Some(ColorLevel::TrueColor))
.hyperlinks(Some(true))
.bracketed_paste(Some(true)),
)
.expect("Capabilities::new_with_hints");
let renderer = TerminfoRenderer::new(caps);
Self {
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.
if self.force_full {
self.mirror = Surface::new(self.cols, self.rows);
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),
});
// 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());
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))?;
// 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.
tty.buf.extend_from_slice(b"\x1b]8;;\x1b\\");
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)
);
}
}

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 = {
lockFile = ./Cargo.lock;
};
cargoBuildFlags = [ "-p" "claude-chill" ];
};
in