commit
This commit is contained in:
27
crates/claude-chill/Cargo.toml
Normal file
27
crates/claude-chill/Cargo.toml
Normal 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"
|
||||
207
crates/claude-chill/src/analyzer.rs
Normal file
207
crates/claude-chill/src/analyzer.rs
Normal 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!("|");
|
||||
}
|
||||
}
|
||||
51
crates/claude-chill/src/bin.rs
Normal file
51
crates/claude-chill/src/bin.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
519
crates/claude-chill/src/escape_parser.rs
Normal file
519
crates/claude-chill/src/escape_parser.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
12
crates/claude-chill/src/escape_sequences.rs
Normal file
12
crates/claude-chill/src/escape_sequences.rs
Normal 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;
|
||||
6
crates/claude-chill/src/lib.rs
Normal file
6
crates/claude-chill/src/lib.rs
Normal 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;
|
||||
214
crates/claude-chill/src/line_buffer.rs
Normal file
214
crates/claude-chill/src/line_buffer.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
197
crates/claude-chill/src/output_processor.rs
Normal file
197
crates/claude-chill/src/output_processor.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
485
crates/claude-chill/src/proxy.rs
Normal file
485
crates/claude-chill/src/proxy.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
79
crates/claude-chill/src/script_parser.rs
Normal file
79
crates/claude-chill/src/script_parser.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user