Add CLI, config file support, and configurable lookback key

- Add clap-based CLI with comprehensive help
- Add TOML config support (~/.config/claude-chill.toml)
- Add key_parser for user-friendly key bindings ([ctrl][shift][j])
- Change default lookback key to Ctrl+Shift+J
- Remove unused modules (analyzer, output_processor, script_parser)
- Add MIT LICENSE file
- Add disclaimer to README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dave Beesley
2026-01-16 21:19:49 -07:00
parent 9db120e05d
commit 961fc90313
15 changed files with 1025 additions and 551 deletions

View File

@@ -16,12 +16,12 @@ path = "src/lib.rs"
name = "claude-chill"
path = "src/bin.rs"
[[bin]]
name = "chill-analyzer"
path = "src/analyzer.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
dirs = "6"
memchr = "2"
nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"] }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
toml = "0.8"

View File

@@ -1,207 +0,0 @@
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

@@ -1,41 +1,42 @@
use claude_chill::proxy::{Proxy, ProxyConfig};
use std::env;
use std::process::ExitCode;
use std::str::FromStr;
mod cli;
fn parse_env_var<T: FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
use clap::Parser;
use claude_chill::config::Config;
use claude_chill::key_parser;
use claude_chill::proxy::{Proxy, ProxyConfig};
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().collect();
let cli = cli::Cli::parse();
let config = Config::load();
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 max_lines = cli.max_lines.unwrap_or(config.max_lines);
let history_lines = cli.history_lines.unwrap_or(config.history_lines);
let command = &args[1];
let cmd_args: Vec<&str> = args[2..].iter().map(|s| s.as_str()).collect();
let lookback_key = cli
.lookback_key
.clone()
.unwrap_or_else(|| config.lookback_key.clone());
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()
let lookback_sequence = match key_parser::parse(&lookback_key) {
Ok(key) => key.to_escape_sequence(),
Err(e) => {
eprintln!("Invalid lookback key '{}': {}", lookback_key, e);
eprintln!("Using default: [ctrl][shift][j]");
config.lookback_sequence()
}
};
match Proxy::spawn(command, &cmd_args, config) {
let proxy_config = ProxyConfig {
max_output_lines: max_lines,
max_history_lines: history_lines,
lookback_key,
lookback_sequence,
};
let cmd_args: Vec<&str> = cli.args.iter().map(|s| s.as_str()).collect();
match Proxy::spawn(&cli.command, &cmd_args, proxy_config) {
Ok(mut proxy) => match proxy.run() {
Ok(exit_code) => ExitCode::from(exit_code as u8),
Err(e) => {

View File

@@ -0,0 +1,63 @@
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "claude-chill",
about = "PTY proxy that reduces terminal flicker by truncating synchronized output",
long_about = "claude-chill sits between your terminal and a child process, intercepting \
synchronized output blocks and truncating them to reduce flicker.\n\n\
Full history is preserved. Press the lookback key (default: Ctrl+Shift+J) \
to dump history to terminal, then scroll up to view it.",
version,
after_help = "USAGE EXAMPLES:\n \
claude-chill claude\n \
claude-chill -- claude --verbose # Use -- for command flags\n \
claude-chill -l 50 -- claude # Set max lines to 50\n\n\
CONFIGURATION:\n \
Create ~/.config/claude-chill.toml:\n\n \
max_lines = 100 # Lines shown per sync block\n \
history_lines = 100000 # Lines stored for lookback\n \
lookback_key = \"[ctrl][shift][j]\"\n\n\
KEY FORMAT: [modifier][key]\n \
Modifiers: [ctrl], [shift], [alt]\n \
Keys: [a]-[z], [f1]-[f12], [pageup], [enter], [space], etc."
)]
pub struct Cli {
#[arg(
help = "Command to run",
required = true,
value_name = "COMMAND"
)]
pub command: String,
#[arg(
help = "Arguments passed to command (use -- before command flags)",
value_name = "ARGS",
trailing_var_arg = true
)]
pub args: Vec<String>,
#[arg(
short = 'l',
long = "max-lines",
help = "Maximum lines per sync block",
value_name = "N"
)]
pub max_lines: Option<usize>,
#[arg(
short = 'H',
long = "history",
help = "Maximum history lines for lookback",
value_name = "N"
)]
pub history_lines: Option<usize>,
#[arg(
short = 'k',
long = "lookback-key",
help = "Key to trigger lookback (e.g., [ctrl][shift][j])",
value_name = "KEY"
)]
pub lookback_key: Option<String>,
}

View File

@@ -0,0 +1,100 @@
use crate::key_parser::{self, KeyCombination};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][shift][j]";
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
pub max_lines: usize,
pub history_lines: usize,
pub lookback_key: String,
}
impl Default for Config {
fn default() -> Self {
Self {
max_lines: 100,
history_lines: 100_000,
lookback_key: DEFAULT_LOOKBACK_KEY.to_string(),
}
}
}
impl Config {
pub fn load() -> Self {
let config_path = Self::config_path();
match config_path {
Some(path) if path.exists() => Self::load_from_file(&path),
_ => Self::default(),
}
}
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("claude-chill.toml"))
}
fn load_from_file(path: &PathBuf) -> Self {
match fs::read_to_string(path) {
Ok(content) => match toml::from_str(&content) {
Ok(config) => config,
Err(e) => {
eprintln!(
"Warning: Failed to parse config file {}: {}",
path.display(),
e
);
Self::default()
}
},
Err(e) => {
eprintln!(
"Warning: Failed to read config file {}: {}",
path.display(),
e
);
Self::default()
}
}
}
pub fn parse_lookback_key(&self) -> Result<KeyCombination, key_parser::ParseKeyError> {
key_parser::parse(&self.lookback_key)
}
pub fn lookback_sequence(&self) -> Vec<u8> {
self.parse_lookback_key()
.map(|k| k.to_escape_sequence())
.unwrap_or_else(|e| {
eprintln!(
"Warning: Invalid lookback_key '{}': {}",
self.lookback_key, e
);
eprintln!("Using default: {}", DEFAULT_LOOKBACK_KEY);
key_parser::parse(DEFAULT_LOOKBACK_KEY)
.map(|k| k.to_escape_sequence())
.unwrap_or_else(|_| b"\x1b[5;6~".to_vec())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.max_lines, 100);
assert_eq!(config.history_lines, 100_000);
assert_eq!(config.lookback_key, "[ctrl][shift][j]");
}
#[test]
fn test_default_lookback_sequence() {
let config = Config::default();
assert_eq!(config.lookback_sequence(), vec![0x0A]);
}
}

View File

@@ -3,7 +3,6 @@ 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;

View File

@@ -0,0 +1,386 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseKeyError {
pub raw: String,
pub reason: String,
}
impl ParseKeyError {
pub fn new(raw: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
raw: raw.into(),
reason: reason.into(),
}
}
}
impl fmt::Display for ParseKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "cannot parse {:?} as key: {}", self.raw, self.reason)
}
}
impl std::error::Error for ParseKeyError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Modifiers {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyCode {
Char(char),
F(u8),
Enter,
Esc,
Tab,
Backspace,
Delete,
Insert,
Home,
End,
PageUp,
PageDown,
Up,
Down,
Left,
Right,
Space,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyCombination {
pub code: KeyCode,
pub modifiers: Modifiers,
}
impl KeyCombination {
pub fn to_escape_sequence(&self) -> Vec<u8> {
key_to_escape_sequence(&self.code, &self.modifiers)
}
}
impl fmt::Display for KeyCombination {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.modifiers.ctrl {
write!(f, "[ctrl]")?;
}
if self.modifiers.shift {
write!(f, "[shift]")?;
}
if self.modifiers.alt {
write!(f, "[alt]")?;
}
let key_name = match &self.code {
KeyCode::Char(c) => format!("[{}]", c),
KeyCode::F(n) => format!("[f{}]", n),
KeyCode::Enter => "[enter]".to_string(),
KeyCode::Esc => "[esc]".to_string(),
KeyCode::Tab => "[tab]".to_string(),
KeyCode::Backspace => "[backspace]".to_string(),
KeyCode::Delete => "[delete]".to_string(),
KeyCode::Insert => "[insert]".to_string(),
KeyCode::Home => "[home]".to_string(),
KeyCode::End => "[end]".to_string(),
KeyCode::PageUp => "[pageup]".to_string(),
KeyCode::PageDown => "[pagedown]".to_string(),
KeyCode::Up => "[up]".to_string(),
KeyCode::Down => "[down]".to_string(),
KeyCode::Left => "[left]".to_string(),
KeyCode::Right => "[right]".to_string(),
KeyCode::Space => "[space]".to_string(),
};
write!(f, "{}", key_name)
}
}
pub fn parse(raw: &str) -> Result<KeyCombination, ParseKeyError> {
let raw_lower = raw.to_ascii_lowercase();
let mut modifiers = Modifiers::default();
let mut key_code: Option<KeyCode> = None;
let mut i = 0;
let chars: Vec<char> = raw_lower.chars().collect();
while i < chars.len() {
if chars[i] != '[' {
return Err(ParseKeyError::new(
raw,
format!("expected '[' at position {}", i),
));
}
let start = i + 1;
let mut end = start;
while end < chars.len() && chars[end] != ']' {
end += 1;
}
if end >= chars.len() {
return Err(ParseKeyError::new(raw, "unclosed bracket"));
}
let token: String = chars[start..end].iter().collect();
i = end + 1;
match token.as_str() {
"ctrl" | "control" => modifiers.ctrl = true,
"shift" => modifiers.shift = true,
"alt" => modifiers.alt = true,
_ => {
if key_code.is_some() {
return Err(ParseKeyError::new(raw, "multiple key codes specified"));
}
key_code = Some(parse_key_code(&token, raw)?);
}
}
}
match key_code {
Some(code) => Ok(KeyCombination { code, modifiers }),
None => Err(ParseKeyError::new(raw, "no key code specified")),
}
}
fn parse_key_code(token: &str, raw: &str) -> Result<KeyCode, ParseKeyError> {
let code = match token {
"[" => KeyCode::Char('['),
"]" => KeyCode::Char(']'),
"esc" | "escape" => KeyCode::Esc,
"enter" | "return" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"backspace" | "bs" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"space" => KeyCode::Space,
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap_or(' ')),
_ => return Err(ParseKeyError::new(raw, format!("unknown key: {}", token))),
};
Ok(code)
}
fn key_to_escape_sequence(code: &KeyCode, modifiers: &Modifiers) -> Vec<u8> {
let modifier_code = match (modifiers.ctrl, modifiers.shift, modifiers.alt) {
(false, false, false) => 0,
_ => 1 + modifiers.shift as u8 + (modifiers.alt as u8 * 2) + (modifiers.ctrl as u8 * 4),
};
match code {
KeyCode::PageUp => modified_key(b"5", modifier_code),
KeyCode::PageDown => modified_key(b"6", modifier_code),
KeyCode::Home => {
if modifier_code == 0 {
b"\x1b[H".to_vec()
} else {
format!("\x1b[1;{}H", modifier_code).into_bytes()
}
}
KeyCode::End => {
if modifier_code == 0 {
b"\x1b[F".to_vec()
} else {
format!("\x1b[1;{}F", modifier_code).into_bytes()
}
}
KeyCode::Up => arrow_key(b'A', modifier_code),
KeyCode::Down => arrow_key(b'B', modifier_code),
KeyCode::Right => arrow_key(b'C', modifier_code),
KeyCode::Left => arrow_key(b'D', modifier_code),
KeyCode::Insert => modified_key(b"2", modifier_code),
KeyCode::Delete => modified_key(b"3", modifier_code),
KeyCode::F(n) => function_key(*n, modifier_code),
KeyCode::Enter => {
if modifiers.alt {
b"\x1b\r".to_vec()
} else {
b"\r".to_vec()
}
}
KeyCode::Tab => {
if modifiers.shift {
b"\x1b[Z".to_vec()
} else {
b"\t".to_vec()
}
}
KeyCode::Esc => b"\x1b".to_vec(),
KeyCode::Backspace => {
if modifiers.ctrl {
vec![0x08]
} else {
vec![0x7f]
}
}
KeyCode::Space => {
if modifiers.ctrl {
vec![0x00]
} else {
b" ".to_vec()
}
}
KeyCode::Char(c) => {
if modifiers.ctrl && c.is_ascii_alphabetic() {
let ctrl_char = (c.to_ascii_uppercase() as u8) - b'A' + 1;
if modifiers.alt {
vec![0x1b, ctrl_char]
} else {
vec![ctrl_char]
}
} else if modifiers.alt {
vec![0x1b, *c as u8]
} else if modifiers.shift {
vec![c.to_ascii_uppercase() as u8]
} else {
vec![*c as u8]
}
}
}
}
fn modified_key(base: &[u8], modifier: u8) -> Vec<u8> {
if modifier == 0 {
format!("\x1b[{}~", std::str::from_utf8(base).unwrap_or("")).into_bytes()
} else {
format!(
"\x1b[{};{}~",
std::str::from_utf8(base).unwrap_or(""),
modifier
)
.into_bytes()
}
}
fn arrow_key(direction: u8, modifier: u8) -> Vec<u8> {
if modifier == 0 {
vec![0x1b, b'[', direction]
} else {
format!("\x1b[1;{}{}", modifier, direction as char).into_bytes()
}
}
fn function_key(n: u8, modifier: u8) -> Vec<u8> {
let code = match n {
1 => 11,
2 => 12,
3 => 13,
4 => 14,
5 => 15,
6 => 17,
7 => 18,
8 => 19,
9 => 20,
10 => 21,
11 => 23,
12 => 24,
_ => return b"\x1b[24~".to_vec(),
};
if modifier == 0 {
format!("\x1b[{}~", code).into_bytes()
} else {
format!("\x1b[{};{}~", code, modifier).into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_key() {
let key = parse("[f12]").unwrap();
assert_eq!(key.code, KeyCode::F(12));
assert!(!key.modifiers.ctrl);
assert!(!key.modifiers.shift);
}
#[test]
fn test_parse_ctrl_shift_pageup() {
let key = parse("[ctrl][shift][pageup]").unwrap();
assert_eq!(key.code, KeyCode::PageUp);
assert!(key.modifiers.ctrl);
assert!(key.modifiers.shift);
assert!(!key.modifiers.alt);
}
#[test]
fn test_parse_alt_enter() {
let key = parse("[alt][enter]").unwrap();
assert_eq!(key.code, KeyCode::Enter);
assert!(key.modifiers.alt);
}
#[test]
fn test_parse_single_char() {
let key = parse("[ctrl][c]").unwrap();
assert_eq!(key.code, KeyCode::Char('c'));
assert!(key.modifiers.ctrl);
}
#[test]
fn test_escape_sequence_ctrl_shift_pageup() {
let key = parse("[ctrl][shift][pageup]").unwrap();
assert_eq!(key.to_escape_sequence(), b"\x1b[5;6~".to_vec());
}
#[test]
fn test_escape_sequence_f12() {
let key = parse("[f12]").unwrap();
assert_eq!(key.to_escape_sequence(), b"\x1b[24~".to_vec());
}
#[test]
fn test_escape_sequence_ctrl_c() {
let key = parse("[ctrl][c]").unwrap();
assert_eq!(key.to_escape_sequence(), vec![0x03]);
}
#[test]
fn test_display() {
let key = parse("[ctrl][shift][pageup]").unwrap();
assert_eq!(key.to_string(), "[ctrl][shift][pageup]");
}
#[test]
fn test_case_insensitive() {
let key = parse("[CTRL][SHIFT][PAGEUP]").unwrap();
assert_eq!(key.code, KeyCode::PageUp);
assert!(key.modifiers.ctrl);
assert!(key.modifiers.shift);
}
#[test]
fn test_error_unclosed_bracket() {
let result = parse("[ctrl");
assert!(result.is_err());
}
#[test]
fn test_error_no_key() {
let result = parse("[ctrl][shift]");
assert!(result.is_err());
}
}

View File

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

View File

@@ -1,197 +0,0 @@
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

@@ -1,8 +1,8 @@
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,
CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, 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};
@@ -36,6 +36,7 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
pub struct ProxyConfig {
pub max_output_lines: usize,
pub max_history_lines: usize,
pub lookback_key: String,
pub lookback_sequence: Vec<u8>,
}
@@ -44,7 +45,8 @@ impl Default for ProxyConfig {
Self {
max_output_lines: 100,
max_history_lines: 100_000,
lookback_sequence: b"\x1b[5;6~".to_vec(),
lookback_key: "[ctrl][shift][j]".to_string(),
lookback_sequence: vec![0x0A],
}
}
}
@@ -352,7 +354,8 @@ impl Proxy {
}
fn enter_lookback_mode(&mut self, stdout_fd: i32) -> Result<()> {
write_all_raw(stdout_fd, LOOKBACK_HEADER)?;
let header = b"\x1b[7m--- LOOKBACK: scroll up to see history ---\x1b[0m\r\n";
write_all_raw(stdout_fd, header)?;
self.output_buffer.clear();
self.history.append_all(&mut self.output_buffer);
write_all_raw(stdout_fd, &self.output_buffer)?;
@@ -426,10 +429,7 @@ fn setup_raw_mode() -> Result<Option<Termios>> {
Ok(Some(original))
}
fn setup_signal_handler(
signal: Signal,
handler: extern "C" fn(libc::c_int),
) -> Result<()> {
fn setup_signal_handler(signal: Signal, handler: extern "C" fn(libc::c_int)) -> Result<()> {
let action = SigAction::new(
SigHandler::Handler(handler),
SaFlags::SA_RESTART,

View File

@@ -1,79 +0,0 @@
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");
}
}