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:
@@ -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"
|
||||
|
||||
@@ -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!("|");
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
63
crates/claude-chill/src/cli.rs
Normal file
63
crates/claude-chill/src/cli.rs
Normal 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>,
|
||||
}
|
||||
100
crates/claude-chill/src/config.rs
Normal file
100
crates/claude-chill/src/config.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
386
crates/claude-chill/src/key_parser.rs
Normal file
386
crates/claude-chill/src/key_parser.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user