This commit is contained in:
Dave Beesley
2026-01-16 20:19:52 -07:00
commit 9db120e05d
14 changed files with 1941 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

61
Cargo.lock generated Normal file
View File

@@ -0,0 +1,61 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "claude-chill"
version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"memchr",
"nix",
]
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]

10
Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[workspace]
resolver = "2"
members = [
"crates/claude-chill",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
authors = ["David Beesley"]

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# claude-chill
A PTY proxy that reduces terminal flicker by truncating synchronized output blocks.
## The Problem
Claude Code (and similar tools) use synchronized output (`\x1b[?2026h` / `\x1b[?2026l`) to update the terminal atomically. When these blocks contain thousands of lines, some terminals struggle to render them smoothly, causing visible flicker.
## The Solution
claude-chill sits between your terminal and the child process, intercepting synchronized output blocks and truncating them to a configurable number of lines. This keeps atomic updates small enough for smooth rendering while preserving full history for lookback.
## Installation
```bash
cargo install --path crates/claude-chill
```
## Usage
```bash
claude-chill <command> [args...]
# Example: wrap Claude Code
claude-chill claude
# Example: wrap any command
claude-chill bash
```
### Environment Variables
- `CHILL_MAX_LINES` - Max lines per sync block (default: 100)
- `CHILL_HISTORY` - Max history lines for lookback (default: 100000)
### Lookback Mode
Press `Ctrl+Shift+PgUp` to dump the full history buffer to the terminal.
## How It Works
1. Creates a PTY pair and spawns the child process
2. Intercepts all output from the child
3. Detects synchronized output blocks (between `?2026h` and `?2026l`)
4. For "full redraw" blocks (containing clear screen + cursor home):
- Stores full content in history buffer
- Outputs only the last N lines
5. Passes all other content through unchanged
## License
MIT

View File

@@ -0,0 +1,27 @@
[package]
name = "claude-chill"
version.workspace = true
edition.workspace = true
authors.workspace = true
default-run = "claude-chill"
description = "PTY proxy that reduces terminal flicker by truncating synchronized output"
license = "MIT"
repository = "https://github.com/davidbeesley/claude-chill"
[lib]
name = "claude_chill"
path = "src/lib.rs"
[[bin]]
name = "claude-chill"
path = "src/bin.rs"
[[bin]]
name = "chill-analyzer"
path = "src/analyzer.rs"
[dependencies]
anyhow = "1"
memchr = "2"
nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"] }
libc = "0.2"

View File

@@ -0,0 +1,207 @@
use claude_chill::escape_sequences::{SYNC_END, SYNC_START};
use claude_chill::script_parser::strip_script_wrapper;
use std::env;
use std::fs;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
eprintln!("Usage: chill-analyzer <original.raw> <chill-output.raw>");
eprintln!();
eprintln!("Compares two script recordings, stripping headers and showing diffs.");
return ExitCode::from(1);
}
let original_path = &args[1];
let pty_path = &args[2];
let original = match fs::read(original_path) {
Ok(data) => data,
Err(e) => {
eprintln!("Failed to read {}: {}", original_path, e);
return ExitCode::from(1);
}
};
let pty_output = match fs::read(pty_path) {
Ok(data) => data,
Err(e) => {
eprintln!("Failed to read {}: {}", pty_path, e);
return ExitCode::from(1);
}
};
let original_content = strip_script_wrapper(&original);
let pty_content = strip_script_wrapper(&pty_output);
println!(
"Original: {} bytes -> {} bytes after stripping",
original.len(),
original_content.len()
);
println!(
"PTY: {} bytes -> {} bytes after stripping",
pty_output.len(),
pty_content.len()
);
println!();
compare_and_report(original_content, pty_content);
ExitCode::SUCCESS
}
fn compare_and_report(original: &[u8], pty: &[u8]) {
let mut first_diff: Option<usize> = None;
let common_len = original.len().min(pty.len());
for i in 0..common_len {
if original[i] != pty[i] {
first_diff = Some(i);
break;
}
}
match first_diff {
Some(pos) => {
println!("FIRST DIFFERENCE at byte {}", pos);
println!();
let context_start = pos.saturating_sub(32);
let context_end = (pos + 64).min(original.len()).min(pty.len());
println!(
"=== ORIGINAL @ {} (diff at +{}) ===",
context_start,
pos - context_start
);
print_hex_with_highlight(
&original[context_start..context_end.min(original.len())],
pos - context_start,
);
println!();
println!(
"=== PTY @ {} (diff at +{}) ===",
context_start,
pos - context_start
);
print_hex_with_highlight(
&pty[context_start..context_end.min(pty.len())],
pos - context_start,
);
println!();
println!("Context around diff:");
println!(
" Original byte: 0x{:02x} ({:?})",
original[pos],
char::from(original[pos])
);
println!(
" PTY byte: 0x{:02x} ({:?})",
pty[pos],
char::from(pty[pos])
);
let sync_before = count_sync_markers(&original[..pos]);
println!();
println!(
"Sync blocks before diff: {} starts, {} ends",
sync_before.0, sync_before.1
);
}
None => {
if original.len() == pty.len() {
println!("FILES ARE IDENTICAL");
} else {
println!("Files match for {} bytes, then one ends early", common_len);
println!(" Original: {} bytes", original.len());
println!(" PTY: {} bytes", pty.len());
if original.len() > pty.len() {
println!();
println!("=== ORIGINAL CONTINUES WITH ===");
print_hex_with_highlight(
&original[common_len..(common_len + 64).min(original.len())],
0,
);
} else {
println!();
println!("=== PTY CONTINUES WITH ===");
print_hex_with_highlight(&pty[common_len..(common_len + 64).min(pty.len())], 0);
}
}
}
}
println!();
println!("=== SYNC BLOCK SUMMARY ===");
let orig_sync = count_sync_markers(original);
let pty_sync = count_sync_markers(pty);
println!(
"Original: {} sync starts, {} sync ends",
orig_sync.0, orig_sync.1
);
println!(
"PTY: {} sync starts, {} sync ends",
pty_sync.0, pty_sync.1
);
}
fn count_sync_markers(data: &[u8]) -> (usize, usize) {
let mut starts = 0;
let mut ends = 0;
for i in 0..data.len().saturating_sub(SYNC_START.len()) {
if &data[i..i + SYNC_START.len()] == SYNC_START {
starts += 1;
}
if &data[i..i + SYNC_END.len()] == SYNC_END {
ends += 1;
}
}
(starts, ends)
}
fn print_hex_with_highlight(data: &[u8], highlight_pos: usize) {
for (i, chunk) in data.chunks(16).enumerate() {
let offset = i * 16;
print!("{:08x}: ", offset);
for (j, &byte) in chunk.iter().enumerate() {
let pos = offset + j;
if pos == highlight_pos {
print!("\x1b[41m{:02x}\x1b[0m ", byte);
} else {
print!("{:02x} ", byte);
}
if j == 7 {
print!(" ");
}
}
for _ in chunk.len()..16 {
print!(" ");
}
print!(" |");
for (j, &byte) in chunk.iter().enumerate() {
let pos = offset + j;
let c = if byte.is_ascii_graphic() || byte == b' ' {
byte as char
} else {
'.'
};
if pos == highlight_pos {
print!("\x1b[41m{}\x1b[0m", c);
} else {
print!("{}", c);
}
}
println!("|");
}
}

View File

@@ -0,0 +1,51 @@
use claude_chill::proxy::{Proxy, ProxyConfig};
use std::env;
use std::process::ExitCode;
use std::str::FromStr;
fn parse_env_var<T: FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn main() -> ExitCode {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: claude-chill <command> [args...]");
eprintln!();
eprintln!("PTY proxy that reduces terminal flicker by truncating synchronized output.");
eprintln!();
eprintln!("Environment variables:");
eprintln!(" CHILL_MAX_LINES Max lines per sync block (default: 100)");
eprintln!(" CHILL_HISTORY Max history lines for lookback (default: 100000)");
eprintln!();
eprintln!("Lookback mode: Press Ctrl+Shift+PgUp to view full history");
return ExitCode::from(1);
}
let command = &args[1];
let cmd_args: Vec<&str> = args[2..].iter().map(|s| s.as_str()).collect();
let config = ProxyConfig {
max_output_lines: parse_env_var("CHILL_MAX_LINES", 100),
max_history_lines: parse_env_var("CHILL_HISTORY", 100_000),
..Default::default()
};
match Proxy::spawn(command, &cmd_args, config) {
Ok(mut proxy) => match proxy.run() {
Ok(exit_code) => ExitCode::from(exit_code as u8),
Err(e) => {
eprintln!("Proxy error: {}", e);
ExitCode::from(1)
}
},
Err(e) => {
eprintln!("Failed to start proxy: {}", e);
ExitCode::from(1)
}
}
}

View File

@@ -0,0 +1,519 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParsedEscape {
SyncStart,
SyncEnd,
ClearScreen,
ClearScrollback,
CursorHome,
CursorUp(u16),
CursorCol(u16),
ClearLine,
Newline,
CarriageReturn,
Sgr(SgrCode),
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SgrCode {
pub reset: bool,
pub fg: Option<Color>,
pub bg: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Color {
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
pub struct EscapeParser {
state: ParserState,
params: Vec<u16>,
intermediate: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParserState {
Ground,
Escape,
CsiEntry,
CsiParam,
CsiIntermediate,
OscString,
DcsString,
}
impl Default for EscapeParser {
fn default() -> Self {
Self::new()
}
}
impl EscapeParser {
pub fn new() -> Self {
Self {
state: ParserState::Ground,
params: Vec::with_capacity(16),
intermediate: Vec::with_capacity(4),
}
}
pub fn in_escape_sequence(&self) -> bool {
self.state != ParserState::Ground
}
pub fn feed(&mut self, byte: u8) -> Option<ParsedEscape> {
match self.state {
ParserState::Ground => self.ground(byte),
ParserState::Escape => self.escape(byte),
ParserState::CsiEntry => self.csi_entry(byte),
ParserState::CsiParam => self.csi_param(byte),
ParserState::CsiIntermediate => self.csi_intermediate(byte),
ParserState::OscString => self.osc_string(byte),
ParserState::DcsString => self.dcs_string(byte),
}
}
fn ground(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
0x1b => {
self.state = ParserState::Escape;
None
}
b'\n' => Some(ParsedEscape::Newline),
b'\r' => Some(ParsedEscape::CarriageReturn),
_ => None,
}
}
fn escape(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
b'[' => {
self.state = ParserState::CsiEntry;
self.params.clear();
self.intermediate.clear();
None
}
b']' => {
self.state = ParserState::OscString;
None
}
b'P' | b'^' | b'_' => {
self.state = ParserState::DcsString;
None
}
_ => {
self.state = ParserState::Ground;
Some(ParsedEscape::Other)
}
}
}
fn osc_string(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
0x07 => {
self.state = ParserState::Ground;
Some(ParsedEscape::Other)
}
0x1b => {
self.state = ParserState::Escape;
None
}
_ => None,
}
}
fn dcs_string(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
0x1b => {
self.state = ParserState::Escape;
None
}
0x9c => {
self.state = ParserState::Ground;
Some(ParsedEscape::Other)
}
_ => None,
}
}
fn csi_entry(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
b'0'..=b'9' => {
self.params.push((byte - b'0') as u16);
self.state = ParserState::CsiParam;
None
}
b';' => {
self.params.push(0);
self.state = ParserState::CsiParam;
None
}
b'?' => {
self.intermediate.push(byte);
self.state = ParserState::CsiIntermediate;
None
}
b'@'..=b'~' => {
self.state = ParserState::Ground;
self.dispatch_csi(byte)
}
_ => {
self.state = ParserState::Ground;
Some(ParsedEscape::Other)
}
}
}
fn csi_param(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
b'0'..=b'9' => {
if let Some(last) = self.params.last_mut() {
*last = last.saturating_mul(10).saturating_add((byte - b'0') as u16);
}
None
}
b';' => {
self.params.push(0);
None
}
b'@'..=b'~' => {
self.state = ParserState::Ground;
self.dispatch_csi(byte)
}
_ => {
self.state = ParserState::Ground;
Some(ParsedEscape::Other)
}
}
}
fn csi_intermediate(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
b'0'..=b'9' => {
if self.params.is_empty() {
self.params.push((byte - b'0') as u16);
} else if let Some(last) = self.params.last_mut() {
*last = last.saturating_mul(10).saturating_add((byte - b'0') as u16);
}
None
}
b';' => {
self.params.push(0);
None
}
b'@'..=b'~' => {
self.state = ParserState::Ground;
self.dispatch_private_csi(byte)
}
_ => {
self.intermediate.push(byte);
None
}
}
}
fn dispatch_csi(&mut self, byte: u8) -> Option<ParsedEscape> {
match byte {
b'H' => {
if self.params.is_empty()
|| (self.params.len() == 2 && self.params[0] <= 1 && self.params[1] <= 1)
{
Some(ParsedEscape::CursorHome)
} else {
Some(ParsedEscape::Other)
}
}
b'J' => {
let param = self.params.first().copied().unwrap_or(0);
match param {
2 => Some(ParsedEscape::ClearScreen),
3 => Some(ParsedEscape::ClearScrollback),
_ => Some(ParsedEscape::Other),
}
}
b'A' => {
let n = self.params.first().copied().unwrap_or(1).max(1);
Some(ParsedEscape::CursorUp(n))
}
b'G' => {
let col = self.params.first().copied().unwrap_or(1);
Some(ParsedEscape::CursorCol(col))
}
b'K' => Some(ParsedEscape::ClearLine),
b'm' => Some(ParsedEscape::Sgr(self.parse_sgr())),
_ => Some(ParsedEscape::Other),
}
}
fn dispatch_private_csi(&mut self, byte: u8) -> Option<ParsedEscape> {
if self.intermediate.first() == Some(&b'?') {
let param = self.params.first().copied().unwrap_or(0);
match (param, byte) {
(2026, b'h') => Some(ParsedEscape::SyncStart),
(2026, b'l') => Some(ParsedEscape::SyncEnd),
_ => Some(ParsedEscape::Other),
}
} else {
Some(ParsedEscape::Other)
}
}
fn parse_sgr(&self) -> SgrCode {
let mut sgr = SgrCode::default();
let mut i = 0;
while i < self.params.len() {
match self.params[i] {
0 => sgr.reset = true,
38 => {
if i + 1 < self.params.len() && self.params[i + 1] == 2 {
if i + 4 < self.params.len() {
let r = self.params[i + 2] as u8;
let g = self.params[i + 3] as u8;
let b = self.params[i + 4] as u8;
sgr.fg = Some(Color::Rgb(r, g, b));
i += 4;
}
} else if i + 1 < self.params.len()
&& self.params[i + 1] == 5
&& i + 2 < self.params.len()
{
sgr.fg = Some(Color::Indexed(self.params[i + 2] as u8));
i += 2;
}
}
48 => {
if i + 1 < self.params.len() && self.params[i + 1] == 2 {
if i + 4 < self.params.len() {
let r = self.params[i + 2] as u8;
let g = self.params[i + 3] as u8;
let b = self.params[i + 4] as u8;
sgr.bg = Some(Color::Rgb(r, g, b));
i += 4;
}
} else if i + 1 < self.params.len()
&& self.params[i + 1] == 5
&& i + 2 < self.params.len()
{
sgr.bg = Some(Color::Indexed(self.params[i + 2] as u8));
i += 2;
}
}
39 => sgr.fg = Some(Color::Default),
49 => sgr.bg = Some(Color::Default),
_ => {}
}
i += 1;
}
sgr
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_sequence(bytes: &[u8]) -> Vec<ParsedEscape> {
let mut parser = EscapeParser::new();
bytes.iter().filter_map(|&b| parser.feed(b)).collect()
}
fn parse_last(bytes: &[u8]) -> Option<ParsedEscape> {
parse_sequence(bytes).into_iter().last()
}
#[test]
fn test_newline() {
assert_eq!(parse_last(b"\n"), Some(ParsedEscape::Newline));
}
#[test]
fn test_carriage_return() {
assert_eq!(parse_last(b"\r"), Some(ParsedEscape::CarriageReturn));
}
#[test]
fn test_clear_screen() {
assert_eq!(parse_last(b"\x1b[2J"), Some(ParsedEscape::ClearScreen));
}
#[test]
fn test_clear_scrollback() {
assert_eq!(parse_last(b"\x1b[3J"), Some(ParsedEscape::ClearScrollback));
}
#[test]
fn test_cursor_home() {
assert_eq!(parse_last(b"\x1b[H"), Some(ParsedEscape::CursorHome));
assert_eq!(parse_last(b"\x1b[1;1H"), Some(ParsedEscape::CursorHome));
}
#[test]
fn test_cursor_home_with_position() {
assert_eq!(parse_last(b"\x1b[5;10H"), Some(ParsedEscape::Other));
}
#[test]
fn test_cursor_up() {
assert_eq!(parse_last(b"\x1b[A"), Some(ParsedEscape::CursorUp(1)));
assert_eq!(parse_last(b"\x1b[5A"), Some(ParsedEscape::CursorUp(5)));
}
#[test]
fn test_cursor_col() {
assert_eq!(parse_last(b"\x1b[G"), Some(ParsedEscape::CursorCol(1)));
assert_eq!(parse_last(b"\x1b[15G"), Some(ParsedEscape::CursorCol(15)));
}
#[test]
fn test_clear_line() {
assert_eq!(parse_last(b"\x1b[K"), Some(ParsedEscape::ClearLine));
}
#[test]
fn test_sync_start() {
assert_eq!(parse_last(b"\x1b[?2026h"), Some(ParsedEscape::SyncStart));
}
#[test]
fn test_sync_end() {
assert_eq!(parse_last(b"\x1b[?2026l"), Some(ParsedEscape::SyncEnd));
}
#[test]
fn test_sgr_reset() {
let result = parse_last(b"\x1b[0m");
assert_eq!(
result,
Some(ParsedEscape::Sgr(SgrCode {
reset: true,
fg: None,
bg: None,
}))
);
}
#[test]
fn test_sgr_default_colors() {
let result = parse_last(b"\x1b[39;49m");
assert_eq!(
result,
Some(ParsedEscape::Sgr(SgrCode {
reset: false,
fg: Some(Color::Default),
bg: Some(Color::Default),
}))
);
}
#[test]
fn test_sgr_indexed_fg() {
let result = parse_last(b"\x1b[38;5;196m");
assert_eq!(
result,
Some(ParsedEscape::Sgr(SgrCode {
reset: false,
fg: Some(Color::Indexed(196)),
bg: None,
}))
);
}
#[test]
fn test_sgr_indexed_bg() {
let result = parse_last(b"\x1b[48;5;21m");
assert_eq!(
result,
Some(ParsedEscape::Sgr(SgrCode {
reset: false,
fg: None,
bg: Some(Color::Indexed(21)),
}))
);
}
#[test]
fn test_sgr_rgb_fg() {
let result = parse_last(b"\x1b[38;2;255;128;0m");
assert_eq!(
result,
Some(ParsedEscape::Sgr(SgrCode {
reset: false,
fg: Some(Color::Rgb(255, 128, 0)),
bg: None,
}))
);
}
#[test]
fn test_sgr_rgb_bg() {
let result = parse_last(b"\x1b[48;2;0;128;255m");
assert_eq!(
result,
Some(ParsedEscape::Sgr(SgrCode {
reset: false,
fg: None,
bg: Some(Color::Rgb(0, 128, 255)),
}))
);
}
#[test]
fn test_osc_title_bel_terminated() {
let events = parse_sequence(b"\x1b]0;My Title\x07");
assert_eq!(events, vec![ParsedEscape::Other]);
}
#[test]
fn test_osc_title_st_terminated() {
let events = parse_sequence(b"\x1b]0;My Title\x1b\\");
assert_eq!(events, vec![ParsedEscape::Other]);
}
#[test]
fn test_dcs_sequence() {
let events = parse_sequence(b"\x1bPsome data\x1b\\");
assert_eq!(events, vec![ParsedEscape::Other]);
}
#[test]
fn test_apc_sequence() {
let events = parse_sequence(b"\x1b_application data\x1b\\");
assert_eq!(events, vec![ParsedEscape::Other]);
}
#[test]
fn test_pm_sequence() {
let events = parse_sequence(b"\x1b^private message\x1b\\");
assert_eq!(events, vec![ParsedEscape::Other]);
}
#[test]
fn test_mixed_content() {
let events = parse_sequence(b"hello\nworld\r\n");
assert_eq!(
events,
vec![
ParsedEscape::Newline,
ParsedEscape::CarriageReturn,
ParsedEscape::Newline
]
);
}
#[test]
fn test_escape_followed_by_text() {
let events = parse_sequence(b"\x1b[2Jhello\n");
assert_eq!(
events,
vec![ParsedEscape::ClearScreen, ParsedEscape::Newline]
);
}
#[test]
fn test_unknown_csi() {
assert_eq!(parse_last(b"\x1b[999z"), Some(ParsedEscape::Other));
}
}

View File

@@ -0,0 +1,12 @@
pub const SYNC_START: &[u8] = b"\x1b[?2026h";
pub const SYNC_END: &[u8] = b"\x1b[?2026l";
pub const CLEAR_SCREEN: &[u8] = b"\x1b[2J";
pub const CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J";
pub const CURSOR_HOME: &[u8] = b"\x1b[H";
pub const LOOKBACK_HEADER: &[u8] = b"\x1b[7m--- LOOKBACK MODE ---\x1b[0m\r\n";
pub const SYNC_BUFFER_CAPACITY: usize = 1024 * 1024;
pub const PASSTHROUGH_BUFFER_CAPACITY: usize = 65536;
pub const OUTPUT_BUFFER_CAPACITY: usize = 32768;
pub const PENDING_ESCAPE_CAPACITY: usize = 32;
pub const INPUT_BUFFER_CAPACITY: usize = 64;

View File

@@ -0,0 +1,6 @@
pub mod escape_parser;
pub mod escape_sequences;
pub mod line_buffer;
pub mod output_processor;
pub mod proxy;
pub mod script_parser;

View File

@@ -0,0 +1,214 @@
use std::collections::VecDeque;
pub struct LineBuffer {
lines: VecDeque<Vec<u8>>,
current_line: Vec<u8>,
max_lines: usize,
cached_bytes: usize,
}
impl LineBuffer {
pub fn new(max_lines: usize) -> Self {
Self {
lines: VecDeque::new(),
current_line: Vec::new(),
max_lines,
cached_bytes: 0,
}
}
pub fn push_byte(&mut self, byte: u8) {
if byte == b'\n' {
let line = std::mem::take(&mut self.current_line);
self.cached_bytes += line.len() + 1;
self.lines.push_back(line);
if self.lines.len() > self.max_lines
&& let Some(removed) = self.lines.pop_front()
{
self.cached_bytes -= removed.len() + 1;
}
} else {
self.current_line.push(byte);
}
}
pub fn push_bytes(&mut self, bytes: &[u8]) {
for &byte in bytes {
self.push_byte(byte);
}
}
pub fn clear(&mut self) {
self.lines.clear();
self.current_line.clear();
self.cached_bytes = 0;
}
pub fn line_count(&self) -> usize {
self.lines.len() + if self.current_line.is_empty() { 0 } else { 1 }
}
pub fn total_bytes(&self) -> usize {
self.cached_bytes + self.current_line.len()
}
pub fn append_last_n_lines(&self, n: usize, output: &mut Vec<u8>) {
let total_lines = self.line_count();
let lines_to_skip = total_lines.saturating_sub(n);
let completed_lines_to_skip = lines_to_skip.min(self.lines.len());
for line in self.lines.iter().skip(completed_lines_to_skip) {
output.extend_from_slice(line);
output.push(b'\n');
}
if !self.current_line.is_empty() && lines_to_skip < total_lines {
output.extend_from_slice(&self.current_line);
}
}
pub fn append_all(&self, output: &mut Vec<u8>) {
for line in &self.lines {
output.extend_from_slice(line);
output.push(b'\n');
}
if !self.current_line.is_empty() {
output.extend_from_slice(&self.current_line);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_all(buf: &LineBuffer) -> Vec<u8> {
let mut result = Vec::new();
buf.append_all(&mut result);
result
}
fn get_last_n(buf: &LineBuffer, n: usize) -> Vec<u8> {
let mut result = Vec::new();
buf.append_last_n_lines(n, &mut result);
result
}
#[test]
fn test_empty_buffer() {
let buf = LineBuffer::new(10);
assert_eq!(buf.line_count(), 0);
assert_eq!(buf.total_bytes(), 0);
assert_eq!(get_all(&buf), Vec::<u8>::new());
}
#[test]
fn test_push_single_line() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"hello\n");
assert_eq!(buf.line_count(), 1);
assert_eq!(buf.total_bytes(), 6);
assert_eq!(get_all(&buf), b"hello\n");
}
#[test]
fn test_push_partial_line() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"hello");
assert_eq!(buf.line_count(), 1);
assert_eq!(buf.total_bytes(), 5);
assert_eq!(get_all(&buf), b"hello");
}
#[test]
fn test_push_multiple_lines() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"line1\nline2\nline3\n");
assert_eq!(buf.line_count(), 3);
assert_eq!(get_all(&buf), b"line1\nline2\nline3\n");
}
#[test]
fn test_max_lines_eviction() {
let mut buf = LineBuffer::new(3);
buf.push_bytes(b"a\nb\nc\nd\ne\n");
assert_eq!(buf.line_count(), 3);
assert_eq!(get_all(&buf), b"c\nd\ne\n");
}
#[test]
fn test_total_bytes_after_eviction() {
let mut buf = LineBuffer::new(2);
buf.push_bytes(b"long_line_1\nshort\nmedium_line\n");
assert_eq!(buf.line_count(), 2);
assert_eq!(buf.total_bytes(), 6 + 12);
assert_eq!(get_all(&buf), b"short\nmedium_line\n");
}
#[test]
fn test_clear() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"line1\nline2\npartial");
buf.clear();
assert_eq!(buf.line_count(), 0);
assert_eq!(buf.total_bytes(), 0);
assert_eq!(get_all(&buf), Vec::<u8>::new());
}
#[test]
fn test_get_last_n_lines_all() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"a\nb\nc\n");
assert_eq!(get_last_n(&buf, 10), b"a\nb\nc\n");
}
#[test]
fn test_get_last_n_lines_subset() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"a\nb\nc\nd\ne\n");
assert_eq!(get_last_n(&buf, 2), b"d\ne\n");
}
#[test]
fn test_get_last_n_lines_with_partial() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"a\nb\nc\npartial");
assert_eq!(buf.line_count(), 4);
assert_eq!(get_last_n(&buf, 2), b"c\npartial");
}
#[test]
fn test_get_last_n_lines_only_partial() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"a\nb\nc\npartial");
assert_eq!(get_last_n(&buf, 1), b"partial");
}
#[test]
fn test_get_last_n_lines_zero() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"a\nb\nc\n");
assert_eq!(get_last_n(&buf, 0), Vec::<u8>::new());
}
#[test]
fn test_crlf_preserved() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"line1\r\nline2\r\n");
assert_eq!(
get_all(&buf),
b"line1\r\nline2\r\n",
"CRLF must be preserved"
);
}
#[test]
fn test_crlf_preserved_in_last_n() {
let mut buf = LineBuffer::new(10);
buf.push_bytes(b"line1\r\nline2\r\nline3\r\n");
assert_eq!(
get_last_n(&buf, 2),
b"line2\r\nline3\r\n",
"CRLF must be preserved in last_n"
);
}
}

View File

@@ -0,0 +1,197 @@
use crate::escape_parser::{EscapeParser, ParsedEscape};
use crate::escape_sequences::{
PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY, SYNC_END, SYNC_START,
};
pub struct OutputProcessor {
parser: EscapeParser,
in_sync_block: bool,
sync_buffer: Vec<u8>,
passthrough_buffer: Vec<u8>,
pending_escape: Vec<u8>,
}
impl Default for OutputProcessor {
fn default() -> Self {
Self::new()
}
}
impl OutputProcessor {
pub fn new() -> Self {
Self {
parser: EscapeParser::new(),
in_sync_block: false,
sync_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY),
passthrough_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY),
pending_escape: Vec::with_capacity(PENDING_ESCAPE_CAPACITY),
}
}
pub fn process(&mut self, data: &[u8]) -> Vec<u8> {
let mut output = Vec::new();
self.passthrough_buffer.clear();
for &byte in data {
let in_escape = self.parser.in_escape_sequence();
if in_escape && self.pending_escape.is_empty() {
self.pending_escape.push(0x1b);
}
if let Some(event) = self.parser.feed(byte) {
match event {
ParsedEscape::SyncStart => {
if !self.passthrough_buffer.is_empty() {
output.extend_from_slice(&self.passthrough_buffer);
self.passthrough_buffer.clear();
}
self.pending_escape.clear();
self.in_sync_block = true;
self.sync_buffer.clear();
self.sync_buffer.extend_from_slice(SYNC_START);
continue;
}
ParsedEscape::SyncEnd => {
self.pending_escape.clear();
if self.in_sync_block {
self.sync_buffer.extend_from_slice(SYNC_END);
output.extend_from_slice(&self.sync_buffer);
self.in_sync_block = false;
}
continue;
}
_ => {
self.flush_pending_escape();
}
}
}
if !self.parser.in_escape_sequence() && !self.pending_escape.is_empty() {
self.flush_pending_escape();
}
if self.parser.in_escape_sequence() {
self.pending_escape.push(byte);
} else if self.in_sync_block {
self.sync_buffer.push(byte);
} else {
self.passthrough_buffer.push(byte);
}
}
if !self.pending_escape.is_empty() {
self.flush_pending_escape();
}
if !self.passthrough_buffer.is_empty() {
output.extend_from_slice(&self.passthrough_buffer);
}
output
}
fn flush_pending_escape(&mut self) {
if self.in_sync_block {
self.sync_buffer.extend_from_slice(&self.pending_escape);
} else {
self.passthrough_buffer
.extend_from_slice(&self.pending_escape);
}
self.pending_escape.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_passthrough_no_sync() {
let mut processor = OutputProcessor::new();
let input = b"hello world\r\n";
let output = processor.process(input);
assert_eq!(output, input);
}
#[test]
fn test_single_sync_block() {
let mut processor = OutputProcessor::new();
let input = b"\x1b[?2026hcontent\x1b[?2026l";
let output = processor.process(input);
assert_eq!(output, input, "Sync block should pass through unchanged");
}
#[test]
fn test_sync_start_no_duplicate_byte() {
let mut processor = OutputProcessor::new();
let input = b"\x1b[?2026hcontent\x1b[?2026l";
let output = processor.process(input);
let sync_start_count = output
.windows(SYNC_START.len())
.filter(|w| *w == SYNC_START)
.count();
assert_eq!(
sync_start_count, 1,
"Should have exactly one SYNC_START, got {}",
sync_start_count
);
let sync_end_count = output
.windows(SYNC_END.len())
.filter(|w| *w == SYNC_END)
.count();
assert_eq!(
sync_end_count, 1,
"Should have exactly one SYNC_END, got {}",
sync_end_count
);
}
#[test]
fn test_content_before_sync() {
let mut processor = OutputProcessor::new();
let input = b"before\x1b[?2026hcontent\x1b[?2026lafter";
let output = processor.process(input);
assert_eq!(output, input);
}
#[test]
fn test_multiple_sync_blocks() {
let mut processor = OutputProcessor::new();
let input = b"\x1b[?2026hblock1\x1b[?2026l\x1b[?2026hblock2\x1b[?2026l";
let output = processor.process(input);
assert_eq!(output, input);
}
#[test]
fn test_carriage_return_preserved() {
let mut processor = OutputProcessor::new();
let input = b"line1\r\nline2\r\n";
let output = processor.process(input);
assert_eq!(output, input, "Carriage returns must be preserved");
}
#[test]
fn test_carriage_return_after_sync_start() {
let mut processor = OutputProcessor::new();
let input = b"\x1b[?2026h\r\ncontent\x1b[?2026l";
let output = processor.process(input);
assert_eq!(
output, input,
"Carriage return after sync start must be preserved"
);
}
#[test]
fn test_carriage_return_in_sync_block() {
let mut processor = OutputProcessor::new();
let input = b"\x1b[?2026hline1\r\nline2\r\n\x1b[?2026l";
let output = processor.process(input);
assert_eq!(
output, input,
"Carriage returns inside sync block must be preserved"
);
}
}

View File

@@ -0,0 +1,485 @@
use crate::escape_parser::{EscapeParser, ParsedEscape};
use crate::escape_sequences::{
CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, LOOKBACK_HEADER,
OUTPUT_BUFFER_CAPACITY, PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY,
SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START,
};
use crate::line_buffer::LineBuffer;
use anyhow::{Context, Result};
use memchr::memmem;
use nix::fcntl::{FcntlArg, OFlag, fcntl};
use nix::pty::{Winsize, openpty};
use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
use nix::sys::termios::{SetArg, Termios, cfmakeraw, tcgetattr, tcsetattr};
use std::io;
use std::os::fd::{AsFd, AsRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::sync::atomic::{AtomicBool, Ordering};
static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigwinch(_: libc::c_int) {
SIGWINCH_RECEIVED.store(true, Ordering::SeqCst);
}
extern "C" fn handle_sigint(_: libc::c_int) {
SIGINT_RECEIVED.store(true, Ordering::SeqCst);
}
extern "C" fn handle_sigterm(_: libc::c_int) {
SIGTERM_RECEIVED.store(true, Ordering::SeqCst);
}
pub struct ProxyConfig {
pub max_output_lines: usize,
pub max_history_lines: usize,
pub lookback_sequence: Vec<u8>,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
max_output_lines: 100,
max_history_lines: 100_000,
lookback_sequence: b"\x1b[5;6~".to_vec(),
}
}
}
pub struct Proxy {
config: ProxyConfig,
pty_master: OwnedFd,
child: Child,
original_termios: Option<Termios>,
history: LineBuffer,
sync_buffer: Vec<u8>,
in_sync_block: bool,
parser: EscapeParser,
input_buffer: Vec<u8>,
passthrough_buffer: Vec<u8>,
output_buffer: Vec<u8>,
pending_escape: Vec<u8>,
clear_screen_finder: memmem::Finder<'static>,
cursor_home_finder: memmem::Finder<'static>,
}
impl Proxy {
pub fn spawn(command: &str, args: &[&str], config: ProxyConfig) -> Result<Self> {
let winsize = get_terminal_size()?;
let pty = openpty(&winsize, None).context("openpty failed")?;
let original_termios = setup_raw_mode()?;
setup_signal_handlers()?;
let slave_fd = pty.slave.as_raw_fd();
let child = unsafe {
Command::new(command)
.args(args)
.pre_exec(move || {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) == -1 {
return Err(io::Error::last_os_error());
}
if libc::dup2(slave_fd, 0) == -1 {
return Err(io::Error::last_os_error());
}
if libc::dup2(slave_fd, 1) == -1 {
return Err(io::Error::last_os_error());
}
if libc::dup2(slave_fd, 2) == -1 {
return Err(io::Error::last_os_error());
}
if slave_fd > 2 {
libc::close(slave_fd);
}
Ok(())
})
.spawn()
.context("spawn failed")?
};
drop(pty.slave);
set_nonblocking(&pty.master)?;
Ok(Self {
history: LineBuffer::new(config.max_history_lines),
config,
pty_master: pty.master,
child,
original_termios,
sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY),
in_sync_block: false,
parser: EscapeParser::new(),
input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
passthrough_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY),
output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY),
pending_escape: Vec::with_capacity(PENDING_ESCAPE_CAPACITY),
clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN),
cursor_home_finder: memmem::Finder::new(CURSOR_HOME),
})
}
pub fn run(&mut self) -> Result<i32> {
let stdin_raw = io::stdin().as_raw_fd();
let stdout_raw = io::stdout().as_raw_fd();
let master_raw = self.pty_master.as_raw_fd();
let mut buf = [0u8; 65536];
loop {
if SIGWINCH_RECEIVED.swap(false, Ordering::SeqCst) {
self.forward_winsize()?;
}
if SIGINT_RECEIVED.swap(false, Ordering::SeqCst) {
self.forward_signal(Signal::SIGINT);
}
if SIGTERM_RECEIVED.swap(false, Ordering::SeqCst) {
self.forward_signal(Signal::SIGTERM);
}
let mut poll_fds = [
libc::pollfd {
fd: master_raw,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: stdin_raw,
events: libc::POLLIN,
revents: 0,
},
];
let poll_ret = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, 100) };
if poll_ret == 0 {
continue;
}
if poll_ret < 0 {
let err = unsafe { *libc::__errno_location() };
if err == libc::EINTR {
continue;
}
anyhow::bail!("poll failed: errno {}", err);
}
if poll_fds[0].revents & libc::POLLIN != 0 {
match libc_read(master_raw, &mut buf) {
Ok(0) => break,
Ok(n) => self.process_output(&buf[..n], stdout_raw)?,
Err(e) if e == libc::EAGAIN => {}
Err(e) if e == libc::EIO => break,
Err(e) => anyhow::bail!("read from pty failed: errno {}", e),
}
}
if poll_fds[0].revents & libc::POLLHUP != 0 {
break;
}
if poll_fds[1].revents & libc::POLLIN != 0 {
match libc_read(stdin_raw, &mut buf) {
Ok(0) => break,
Ok(n) => self.process_input(&buf[..n], stdout_raw)?,
Err(e) if e == libc::EAGAIN => {}
Err(e) => anyhow::bail!("read from stdin failed: errno {}", e),
}
}
}
if !self.sync_buffer.is_empty() {
write_all_raw(stdout_raw, &self.sync_buffer)?;
}
self.wait_child()
}
fn process_output(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> {
self.passthrough_buffer.clear();
for &byte in data {
let in_escape = self.parser.in_escape_sequence();
if in_escape && self.pending_escape.is_empty() {
self.pending_escape.push(0x1b);
}
if let Some(event) = self.parser.feed(byte) {
match event {
ParsedEscape::SyncStart => {
if !self.passthrough_buffer.is_empty() {
write_all_raw(stdout_fd, &self.passthrough_buffer)?;
self.passthrough_buffer.clear();
}
self.pending_escape.clear();
self.in_sync_block = true;
self.sync_buffer.clear();
self.sync_buffer.extend_from_slice(SYNC_START);
continue;
}
ParsedEscape::SyncEnd => {
self.pending_escape.clear();
if self.in_sync_block {
self.sync_buffer.extend_from_slice(SYNC_END);
self.flush_sync_block(stdout_fd)?;
self.in_sync_block = false;
}
continue;
}
_ => {
self.flush_pending_escape();
}
}
}
if !self.parser.in_escape_sequence() && !self.pending_escape.is_empty() {
self.flush_pending_escape();
}
if self.parser.in_escape_sequence() {
self.pending_escape.push(byte);
} else if self.in_sync_block {
self.sync_buffer.push(byte);
} else {
self.passthrough_buffer.push(byte);
}
}
if !self.pending_escape.is_empty() && !self.parser.in_escape_sequence() {
self.flush_pending_escape();
}
if !self.passthrough_buffer.is_empty() {
write_all_raw(stdout_fd, &self.passthrough_buffer)?;
}
Ok(())
}
fn flush_pending_escape(&mut self) {
if self.in_sync_block {
self.sync_buffer.extend_from_slice(&self.pending_escape);
} else {
self.passthrough_buffer
.extend_from_slice(&self.pending_escape);
}
self.pending_escape.clear();
}
fn flush_sync_block(&mut self, stdout_fd: i32) -> Result<()> {
let has_clear_screen = self.clear_screen_finder.find(&self.sync_buffer).is_some();
let has_cursor_home = self.cursor_home_finder.find(&self.sync_buffer).is_some();
let is_full_redraw = has_clear_screen && has_cursor_home;
if is_full_redraw {
self.history.clear();
let content_start = self.find_content_start();
let content_end = self.sync_buffer.len().saturating_sub(SYNC_END.len());
if content_start < content_end {
self.history
.push_bytes(&self.sync_buffer[content_start..content_end]);
}
self.create_truncated_output();
write_all_raw(stdout_fd, &self.output_buffer)?;
} else {
write_all_raw(stdout_fd, &self.sync_buffer)?;
}
Ok(())
}
fn create_truncated_output(&mut self) {
self.output_buffer.clear();
self.output_buffer.extend_from_slice(SYNC_START);
self.output_buffer.extend_from_slice(CLEAR_SCREEN);
self.output_buffer.extend_from_slice(CLEAR_SCROLLBACK);
self.output_buffer.extend_from_slice(CURSOR_HOME);
self.history
.append_last_n_lines(self.config.max_output_lines, &mut self.output_buffer);
self.output_buffer.extend_from_slice(SYNC_END);
}
fn find_content_start(&self) -> usize {
let sync_start_finder = memmem::Finder::new(SYNC_START);
let clear_screen_finder = memmem::Finder::new(CLEAR_SCREEN);
let clear_scrollback_finder = memmem::Finder::new(CLEAR_SCROLLBACK);
let cursor_home_finder = memmem::Finder::new(CURSOR_HOME);
let mut pos = 0;
if let Some(idx) = sync_start_finder.find(&self.sync_buffer[pos..]) {
pos += idx + SYNC_START.len();
}
if let Some(idx) = clear_screen_finder.find(&self.sync_buffer[pos..]) {
pos += idx + CLEAR_SCREEN.len();
}
if let Some(idx) = clear_scrollback_finder.find(&self.sync_buffer[pos..]) {
pos += idx + CLEAR_SCROLLBACK.len();
}
if let Some(idx) = cursor_home_finder.find(&self.sync_buffer[pos..]) {
pos += idx + CURSOR_HOME.len();
}
pos
}
fn process_input(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> {
let master_fd = self.pty_master.as_raw_fd();
for &byte in data {
self.input_buffer.push(byte);
if self.input_buffer.len() > self.config.lookback_sequence.len() {
let excess = self.input_buffer.len() - self.config.lookback_sequence.len();
write_all_raw(master_fd, &self.input_buffer[..excess])?;
self.input_buffer.drain(..excess);
}
if self.input_buffer == self.config.lookback_sequence {
self.enter_lookback_mode(stdout_fd)?;
self.input_buffer.clear();
} else if !self
.config
.lookback_sequence
.starts_with(&self.input_buffer)
{
write_all_raw(master_fd, &self.input_buffer)?;
self.input_buffer.clear();
}
}
Ok(())
}
fn enter_lookback_mode(&mut self, stdout_fd: i32) -> Result<()> {
write_all_raw(stdout_fd, LOOKBACK_HEADER)?;
self.output_buffer.clear();
self.history.append_all(&mut self.output_buffer);
write_all_raw(stdout_fd, &self.output_buffer)?;
Ok(())
}
fn forward_winsize(&self) -> Result<()> {
if let Ok(winsize) = get_terminal_size() {
unsafe {
libc::ioctl(self.pty_master.as_raw_fd(), libc::TIOCSWINSZ, &winsize);
}
}
Ok(())
}
fn forward_signal(&self, signal: Signal) {
let pid = self.child.id();
unsafe {
libc::kill(pid as i32, signal as i32);
}
}
fn wait_child(&mut self) -> Result<i32> {
match self.child.wait() {
Ok(status) => Ok(exit_code_from_status(status)),
Err(e) => anyhow::bail!("wait failed: {}", e),
}
}
}
impl Drop for Proxy {
fn drop(&mut self) {
if let Some(ref termios) = self.original_termios {
let _ = tcsetattr(io::stdin(), SetArg::TCSANOW, termios);
}
}
}
fn get_terminal_size() -> Result<Winsize> {
let mut ws: Winsize = unsafe { std::mem::zeroed() };
let ret = unsafe { libc::ioctl(io::stdout().as_raw_fd(), libc::TIOCGWINSZ, &mut ws) };
if ret == -1 {
ws.ws_row = 24;
ws.ws_col = 80;
}
Ok(ws)
}
fn exit_code_from_status(status: ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
if let Some(code) = status.code() {
code
} else if let Some(signal) = status.signal() {
128 + signal
} else {
1
}
}
fn setup_raw_mode() -> Result<Option<Termios>> {
let stdin = io::stdin();
let is_tty = unsafe { libc::isatty(stdin.as_raw_fd()) == 1 };
if !is_tty {
return Ok(None);
}
let original = tcgetattr(&stdin).context("tcgetattr failed")?;
let mut raw = original.clone();
cfmakeraw(&mut raw);
tcsetattr(&stdin, SetArg::TCSANOW, &raw).context("tcsetattr failed")?;
Ok(Some(original))
}
fn setup_signal_handler(
signal: Signal,
handler: extern "C" fn(libc::c_int),
) -> Result<()> {
let action = SigAction::new(
SigHandler::Handler(handler),
SaFlags::SA_RESTART,
SigSet::empty(),
);
unsafe { sigaction(signal, &action) }.context(format!("sigaction {:?} failed", signal))?;
Ok(())
}
fn setup_signal_handlers() -> Result<()> {
setup_signal_handler(Signal::SIGWINCH, handle_sigwinch)?;
setup_signal_handler(Signal::SIGINT, handle_sigint)?;
setup_signal_handler(Signal::SIGTERM, handle_sigterm)?;
Ok(())
}
fn set_nonblocking<Fd: AsFd>(fd: &Fd) -> Result<()> {
let flags = fcntl(fd.as_fd(), FcntlArg::F_GETFL).context("fcntl F_GETFL failed")?;
let flags = OFlag::from_bits_truncate(flags);
fcntl(fd.as_fd(), FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK))
.context("fcntl F_SETFL failed")?;
Ok(())
}
fn write_all_raw(fd: i32, data: &[u8]) -> Result<()> {
let mut written = 0;
while written < data.len() {
match libc_write(fd, &data[written..]) {
Ok(n) => written += n,
Err(e) if e == libc::EAGAIN || e == libc::EINTR => continue,
Err(e) => anyhow::bail!("write failed: errno {}", e),
}
}
Ok(())
}
fn libc_read(fd: i32, buf: &mut [u8]) -> Result<usize, i32> {
let ret = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if ret < 0 {
Err(unsafe { *libc::__errno_location() })
} else {
Ok(ret as usize)
}
}
fn libc_write(fd: i32, buf: &[u8]) -> Result<usize, i32> {
let ret = unsafe { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()) };
if ret < 0 {
Err(unsafe { *libc::__errno_location() })
} else {
Ok(ret as usize)
}
}

View File

@@ -0,0 +1,79 @@
pub fn find_script_header_end(data: &[u8]) -> usize {
if !data.starts_with(b"Script started on") {
return 0;
}
for (i, &byte) in data.iter().enumerate() {
if byte == b'\n' {
return i + 1;
}
}
0
}
pub fn find_script_footer_start(data: &[u8]) -> usize {
let footer_marker = b"\nScript done on";
for i in (0..data.len().saturating_sub(footer_marker.len())).rev() {
if &data[i..i + footer_marker.len()] == footer_marker {
return i;
}
}
data.len()
}
pub fn strip_script_wrapper(data: &[u8]) -> &[u8] {
let mut result = data;
loop {
let header_end = find_script_header_end(result);
if header_end == 0 {
break;
}
result = &result[header_end..];
}
loop {
let footer_start = find_script_footer_start(result);
if footer_start >= result.len() {
break;
}
result = &result[..footer_start];
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_header_end_no_header() {
let data = b"hello world";
assert_eq!(find_script_header_end(data), 0);
}
#[test]
fn test_find_header_end_with_header() {
let data = b"Script started on 2024-01-01\ncontent here";
assert_eq!(find_script_header_end(data), 29);
}
#[test]
fn test_find_footer_start_no_footer() {
let data = b"hello world";
assert_eq!(find_script_footer_start(data), data.len());
}
#[test]
fn test_find_footer_start_with_footer() {
let data = b"content here\nScript done on 2024-01-01";
assert_eq!(find_script_footer_start(data), 12);
}
#[test]
fn test_strip_script_wrapper() {
let data = b"Script started on 2024-01-01\ncontent\nScript done on 2024-01-01";
let stripped = strip_script_wrapper(data);
assert_eq!(stripped, b"content");
}
}