@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
default-run = "claude-chill"
|
||||
description = "PTY proxy that reduces terminal flicker by truncating synchronized output"
|
||||
description = "PTY proxy that reduces terminal flicker using VT-based rendering"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/davidbeesley/claude-chill"
|
||||
|
||||
@@ -25,3 +25,6 @@ nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"]
|
||||
libc = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
vt100 = "0.16"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -7,10 +7,25 @@ use claude_chill::proxy::{Proxy, ProxyConfig};
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Only enable logging if CLAUDE_CHILL_LOG_FILE is set
|
||||
if let Ok(log_file) = std::env::var("CLAUDE_CHILL_LOG_FILE") {
|
||||
use std::fs::OpenOptions;
|
||||
if let Ok(file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&log_file)
|
||||
{
|
||||
env_logger::Builder::new()
|
||||
.filter_level(log::LevelFilter::Debug)
|
||||
.target(env_logger::Target::Pipe(Box::new(file)))
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
let cli = cli::Cli::parse();
|
||||
let config = Config::load();
|
||||
|
||||
let max_lines = cli.max_lines.unwrap_or(config.max_lines);
|
||||
let history_lines = cli.history_lines.unwrap_or(config.history_lines);
|
||||
|
||||
let lookback_key = cli
|
||||
@@ -22,13 +37,12 @@ fn main() -> ExitCode {
|
||||
Ok(key) => key.to_escape_sequence(),
|
||||
Err(e) => {
|
||||
eprintln!("Invalid lookback key '{}': {}", lookback_key, e);
|
||||
eprintln!("Using default: [ctrl][shift][j]");
|
||||
eprintln!("Using default: [ctrl][6]");
|
||||
config.lookback_sequence()
|
||||
}
|
||||
};
|
||||
|
||||
let proxy_config = ProxyConfig {
|
||||
max_output_lines: max_lines,
|
||||
max_history_lines: history_lines,
|
||||
lookback_key,
|
||||
lookback_sequence,
|
||||
|
||||
@@ -1,59 +1,16 @@
|
||||
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."
|
||||
)]
|
||||
#[command(name = "claude-chill", version)]
|
||||
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
|
||||
)]
|
||||
#[arg(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"
|
||||
)]
|
||||
#[arg(short = 'H', long = "history")]
|
||||
pub history_lines: Option<usize>,
|
||||
|
||||
#[arg(
|
||||
short = 'k',
|
||||
long = "lookback-key",
|
||||
help = "Key to trigger lookback (e.g., [ctrl][shift][j])",
|
||||
value_name = "KEY"
|
||||
)]
|
||||
#[arg(short = 'k', long = "lookback-key")]
|
||||
pub lookback_key: Option<String>,
|
||||
}
|
||||
|
||||
@@ -4,21 +4,22 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][6]";
|
||||
const DEFAULT_REFRESH_RATE: u64 = 20;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub max_lines: usize,
|
||||
pub history_lines: usize,
|
||||
pub lookback_key: String,
|
||||
pub refresh_rate: u64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_lines: 100,
|
||||
history_lines: 100_000,
|
||||
lookback_key: DEFAULT_LOOKBACK_KEY.to_string(),
|
||||
refresh_rate: DEFAULT_REFRESH_RATE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +79,11 @@ impl Config {
|
||||
.unwrap_or_else(|_| b"\x1b[5;6~".to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn redraw_throttle_ms(&self) -> u64 {
|
||||
let rate = self.refresh_rate.max(1);
|
||||
1000 / rate
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -87,9 +93,10 @@ mod tests {
|
||||
#[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][6]");
|
||||
assert_eq!(config.refresh_rate, 20);
|
||||
assert_eq!(config.redraw_throttle_ms(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod escape_sequences;
|
||||
pub mod key_parser;
|
||||
pub mod line_buffer;
|
||||
pub mod proxy;
|
||||
pub mod redraw_throttler;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::escape_sequences::{
|
||||
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY,
|
||||
CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY,
|
||||
SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START,
|
||||
CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
|
||||
SYNC_END, SYNC_START,
|
||||
};
|
||||
use crate::line_buffer::LineBuffer;
|
||||
use anyhow::{Context, Result};
|
||||
use log::debug;
|
||||
use memchr::memmem;
|
||||
use nix::errno::Errno;
|
||||
use nix::fcntl::{FcntlArg, OFlag, fcntl};
|
||||
@@ -18,11 +19,19 @@ use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Child, Command, ExitStatus};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
|
||||
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
|
||||
static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SequenceMatch {
|
||||
Complete,
|
||||
Partial,
|
||||
None,
|
||||
}
|
||||
|
||||
extern "C" fn handle_sigwinch(_: libc::c_int) {
|
||||
SIGWINCH_RECEIVED.store(true, Ordering::SeqCst);
|
||||
}
|
||||
@@ -36,7 +45,6 @@ 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>,
|
||||
@@ -45,7 +53,6 @@ pub struct ProxyConfig {
|
||||
impl Default for ProxyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_output_lines: 100,
|
||||
max_history_lines: 100_000,
|
||||
lookback_key: "[ctrl][6]".to_string(),
|
||||
lookback_sequence: vec![0x1E],
|
||||
@@ -76,18 +83,25 @@ impl Drop for TerminalGuard {
|
||||
}
|
||||
}
|
||||
|
||||
const RENDER_DELAY_MS: u64 = 5;
|
||||
const SYNC_BLOCK_DELAY_MS: u64 = 50;
|
||||
|
||||
pub struct Proxy {
|
||||
config: ProxyConfig,
|
||||
pty_master: OwnedFd,
|
||||
child: Child,
|
||||
original_termios: Option<Termios>,
|
||||
history: LineBuffer,
|
||||
vt_parser: vt100::Parser,
|
||||
vt_prev_screen: Option<vt100::Screen>,
|
||||
last_output_time: Option<Instant>,
|
||||
sync_buffer: Vec<u8>,
|
||||
in_sync_block: bool,
|
||||
in_lookback_mode: bool,
|
||||
in_alternate_screen: bool,
|
||||
vt_render_pending: bool,
|
||||
lookback_cache: Vec<u8>,
|
||||
input_buffer: Vec<u8>,
|
||||
lookback_input_buffer: Vec<u8>,
|
||||
output_buffer: Vec<u8>,
|
||||
sync_start_finder: memmem::Finder<'static>,
|
||||
sync_end_finder: memmem::Finder<'static>,
|
||||
@@ -140,18 +154,31 @@ impl Proxy {
|
||||
drop(pty.slave);
|
||||
set_nonblocking(&pty.master)?;
|
||||
|
||||
let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0);
|
||||
|
||||
// Seed history with clear screen so replay starts fresh
|
||||
let mut history = LineBuffer::new(config.max_history_lines);
|
||||
history.push_bytes(CLEAR_SCREEN);
|
||||
history.push_bytes(CURSOR_HOME);
|
||||
|
||||
debug!("Proxy::spawn: command={} args={:?}", command, args);
|
||||
|
||||
Ok(Self {
|
||||
history: LineBuffer::new(config.max_history_lines),
|
||||
history,
|
||||
config,
|
||||
pty_master: pty.master,
|
||||
child,
|
||||
original_termios: terminal_guard.take(),
|
||||
vt_parser,
|
||||
vt_prev_screen: None,
|
||||
last_output_time: None,
|
||||
sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY),
|
||||
in_sync_block: false,
|
||||
in_lookback_mode: false,
|
||||
in_alternate_screen: false,
|
||||
vt_render_pending: false,
|
||||
lookback_cache: Vec::new(),
|
||||
input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
|
||||
lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
|
||||
output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY),
|
||||
sync_start_finder: memmem::Finder::new(SYNC_START),
|
||||
sync_end_finder: memmem::Finder::new(SYNC_END),
|
||||
@@ -189,13 +216,23 @@ impl Proxy {
|
||||
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
|
||||
];
|
||||
|
||||
match poll(&mut poll_fds, PollTimeout::from(100u16)) {
|
||||
Ok(0) => continue,
|
||||
let poll_timeout_ms = self
|
||||
.time_until_render()
|
||||
.map(|d| d.as_millis().min(100) as u16)
|
||||
.unwrap_or(100);
|
||||
|
||||
match poll(&mut poll_fds, PollTimeout::from(poll_timeout_ms)) {
|
||||
Ok(0) => {
|
||||
self.flush_pending_vt_render(&stdout_fd)?;
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(Errno::EINTR) => continue,
|
||||
Err(e) => anyhow::bail!("poll failed: {}", e),
|
||||
}
|
||||
|
||||
self.flush_pending_vt_render(&stdout_fd)?;
|
||||
|
||||
if let Some(revents) = poll_fds[0].revents() {
|
||||
if revents.contains(PollFlags::POLLIN) {
|
||||
match nix_read(&self.pty_master, &mut buf) {
|
||||
@@ -223,47 +260,86 @@ impl Proxy {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.sync_buffer.is_empty() {
|
||||
write_all(&stdout_fd, &self.sync_buffer)?;
|
||||
// Final render before exit
|
||||
if self.vt_render_pending {
|
||||
self.render_vt_screen(&stdout_fd)?;
|
||||
}
|
||||
|
||||
self.wait_child()
|
||||
}
|
||||
|
||||
fn process_output<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||
self.process_output_inner(data, stdout_fd, true)
|
||||
}
|
||||
|
||||
fn process_output_inner<F: AsFd>(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
stdout_fd: &F,
|
||||
feed_vt: bool,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
"process_output: len={} in_alt={} in_lookback={} feed_vt={}",
|
||||
data.len(),
|
||||
self.in_alternate_screen,
|
||||
self.in_lookback_mode,
|
||||
feed_vt
|
||||
);
|
||||
|
||||
if self.in_alternate_screen {
|
||||
// Still feed VT and history while in alt screen so they stay in sync
|
||||
if feed_vt {
|
||||
self.vt_parser.process(data);
|
||||
self.history.push_bytes(data);
|
||||
}
|
||||
return self.process_output_alt_screen(data, stdout_fd);
|
||||
}
|
||||
|
||||
if self.in_lookback_mode {
|
||||
debug!("process_output: caching {} bytes for lookback", data.len());
|
||||
self.lookback_cache.extend_from_slice(data);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
// Feed data to VT emulator (unless already fed by caller)
|
||||
if feed_vt {
|
||||
self.vt_parser.process(data);
|
||||
}
|
||||
self.vt_render_pending = true;
|
||||
self.last_output_time = Some(Instant::now());
|
||||
|
||||
// Process sync blocks for history management
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
// Check for alt screen enter
|
||||
if let Some(alt_pos) = self.find_alt_screen_enter(&data[pos..]) {
|
||||
debug!(
|
||||
"process_output: ALT_SCREEN_ENTER detected at pos={}",
|
||||
pos + alt_pos
|
||||
);
|
||||
// Add ALL remaining data to history (including alt screen enter and content)
|
||||
// This ensures history matches VT exactly
|
||||
let remaining = &data[pos..];
|
||||
if self.in_sync_block {
|
||||
self.sync_buffer
|
||||
.extend_from_slice(&data[pos..pos + alt_pos]);
|
||||
write_all(stdout_fd, &self.sync_buffer)?;
|
||||
self.sync_buffer.clear();
|
||||
self.sync_buffer.extend_from_slice(remaining);
|
||||
self.flush_sync_block_to_history();
|
||||
self.in_sync_block = false;
|
||||
} else if alt_pos > 0 {
|
||||
write_all(stdout_fd, &data[pos..pos + alt_pos])?;
|
||||
} else {
|
||||
self.history.push_bytes(remaining);
|
||||
}
|
||||
self.in_alternate_screen = true;
|
||||
let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]);
|
||||
// Write alt screen enter directly
|
||||
write_all(stdout_fd, &data[pos + alt_pos..pos + alt_pos + seq_len])?;
|
||||
return self.process_output_alt_screen(&data[pos + alt_pos + seq_len..], stdout_fd);
|
||||
}
|
||||
|
||||
if self.in_sync_block {
|
||||
if let Some(idx) = self.sync_end_finder.find(&data[pos..]) {
|
||||
debug!("process_output: SYNC_END at pos={}", pos + idx);
|
||||
self.sync_buffer.extend_from_slice(&data[pos..pos + idx]);
|
||||
self.sync_buffer.extend_from_slice(SYNC_END);
|
||||
self.flush_sync_block(stdout_fd)?;
|
||||
self.flush_sync_block_to_history();
|
||||
self.in_sync_block = false;
|
||||
pos += idx + SYNC_END.len();
|
||||
} else {
|
||||
@@ -271,15 +347,18 @@ impl Proxy {
|
||||
break;
|
||||
}
|
||||
} else if let Some(idx) = self.sync_start_finder.find(&data[pos..]) {
|
||||
debug!("process_output: SYNC_START at pos={}", pos + idx);
|
||||
// Add any data before SYNC_START to history
|
||||
if idx > 0 {
|
||||
write_all(stdout_fd, &data[pos..pos + idx])?;
|
||||
self.history.push_bytes(&data[pos..pos + idx]);
|
||||
}
|
||||
self.in_sync_block = true;
|
||||
self.sync_buffer.clear();
|
||||
self.sync_buffer.extend_from_slice(SYNC_START);
|
||||
pos += idx + SYNC_START.len();
|
||||
} else {
|
||||
write_all(stdout_fd, &data[pos..])?;
|
||||
// No sync block, just add to history
|
||||
self.history.push_bytes(&data[pos..]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -289,15 +368,50 @@ impl Proxy {
|
||||
|
||||
fn process_output_alt_screen<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||
if let Some(exit_pos) = self.find_alt_screen_exit(data) {
|
||||
debug!(
|
||||
"process_output_alt_screen: ALT_SCREEN_EXIT detected at pos={}",
|
||||
exit_pos
|
||||
);
|
||||
write_all(stdout_fd, &data[..exit_pos])?;
|
||||
let seq_len = self.alt_screen_exit_len(&data[exit_pos..]);
|
||||
write_all(stdout_fd, &data[exit_pos..exit_pos + seq_len])?;
|
||||
self.in_alternate_screen = false;
|
||||
return self.process_output(&data[exit_pos + seq_len..], stdout_fd);
|
||||
|
||||
// Force full VT render to restore main screen content
|
||||
debug!("process_output_alt_screen: rendering VT screen after alt exit");
|
||||
self.vt_prev_screen = None;
|
||||
self.render_vt_screen(stdout_fd)?;
|
||||
|
||||
// Data after ALT_EXIT was already fed to VT and history when we processed
|
||||
// the alt screen chunk, so we just need to check for more alt screen transitions
|
||||
let remaining = &data[exit_pos + seq_len..];
|
||||
if !remaining.is_empty() {
|
||||
// Check if there's another alt screen enter in the remaining data
|
||||
if self.find_alt_screen_enter(remaining).is_some() {
|
||||
// Need to process for alt screen detection, but skip VT/history feed
|
||||
return self.process_output_check_alt_only(remaining, stdout_fd);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
write_all(stdout_fd, data)
|
||||
}
|
||||
|
||||
/// Check for alt screen transitions without re-feeding VT/history
|
||||
fn process_output_check_alt_only<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||
if let Some(alt_pos) = self.find_alt_screen_enter(data) {
|
||||
debug!(
|
||||
"process_output_check_alt_only: ALT_SCREEN_ENTER at pos={}",
|
||||
alt_pos
|
||||
);
|
||||
self.in_alternate_screen = true;
|
||||
let seq_len = self.alt_screen_enter_len(&data[alt_pos..]);
|
||||
write_all(stdout_fd, &data[alt_pos..alt_pos + seq_len])?;
|
||||
return self.process_output_alt_screen(&data[alt_pos + seq_len..], stdout_fd);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_alt_screen_enter(&self, data: &[u8]) -> Option<usize> {
|
||||
let pos1 = self.alt_screen_enter_finder.find(data);
|
||||
let pos2 = self.alt_screen_enter_legacy_finder.find(data);
|
||||
@@ -336,36 +450,108 @@ impl Proxy {
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_sync_block<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||
fn flush_sync_block_to_history(&mut self) {
|
||||
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;
|
||||
|
||||
debug!(
|
||||
"flush_sync_block: len={} full_redraw={}",
|
||||
self.sync_buffer.len(),
|
||||
is_full_redraw
|
||||
);
|
||||
|
||||
if is_full_redraw {
|
||||
debug!("CLEARING HISTORY");
|
||||
self.history.clear();
|
||||
// Re-seed with clear screen after clearing
|
||||
self.history.push_bytes(CLEAR_SCREEN);
|
||||
self.history.push_bytes(CURSOR_HOME);
|
||||
}
|
||||
self.history.push_bytes(&self.sync_buffer);
|
||||
self.sync_buffer.clear();
|
||||
}
|
||||
|
||||
if is_full_redraw {
|
||||
self.create_truncated_output();
|
||||
write_all(stdout_fd, &self.output_buffer)?;
|
||||
} else {
|
||||
write_all(stdout_fd, &self.sync_buffer)?;
|
||||
fn flush_pending_vt_render<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||
if !self.vt_render_pending || self.in_lookback_mode || self.in_alternate_screen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let elapsed = self
|
||||
.last_output_time
|
||||
.map(|t| t.elapsed())
|
||||
.unwrap_or(Duration::MAX);
|
||||
|
||||
// Wait longer if in sync block (more data likely coming)
|
||||
let delay = if self.in_sync_block {
|
||||
Duration::from_millis(SYNC_BLOCK_DELAY_MS)
|
||||
} else {
|
||||
Duration::from_millis(RENDER_DELAY_MS)
|
||||
};
|
||||
|
||||
if elapsed >= delay {
|
||||
self.render_vt_screen(stdout_fd)?;
|
||||
}
|
||||
|
||||
self.sync_buffer.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_truncated_output(&mut self) {
|
||||
fn time_until_render(&self) -> Option<Duration> {
|
||||
if !self.vt_render_pending || self.in_lookback_mode || self.in_alternate_screen {
|
||||
return None;
|
||||
}
|
||||
|
||||
let elapsed = self
|
||||
.last_output_time
|
||||
.map(|t| t.elapsed())
|
||||
.unwrap_or(Duration::MAX);
|
||||
|
||||
let delay = if self.in_sync_block {
|
||||
Duration::from_millis(SYNC_BLOCK_DELAY_MS)
|
||||
} else {
|
||||
Duration::from_millis(RENDER_DELAY_MS)
|
||||
};
|
||||
|
||||
if elapsed >= delay {
|
||||
Some(Duration::ZERO)
|
||||
} else {
|
||||
Some(delay - elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_vt_screen<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||
let is_diff = self.vt_prev_screen.is_some();
|
||||
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);
|
||||
|
||||
match &self.vt_prev_screen {
|
||||
Some(prev) => {
|
||||
// Diff-based render: only send changes
|
||||
self.output_buffer
|
||||
.extend_from_slice(&self.vt_parser.screen().contents_diff(prev));
|
||||
}
|
||||
None => {
|
||||
// First render: full screen
|
||||
self.output_buffer
|
||||
.extend_from_slice(&self.vt_parser.screen().contents_formatted());
|
||||
}
|
||||
}
|
||||
|
||||
self.output_buffer
|
||||
.extend_from_slice(&self.vt_parser.screen().cursor_state_formatted());
|
||||
self.output_buffer.extend_from_slice(SYNC_END);
|
||||
|
||||
debug!(
|
||||
"render_vt_screen: diff={} output_len={}\n",
|
||||
is_diff,
|
||||
self.output_buffer.len()
|
||||
);
|
||||
write_all(stdout_fd, &self.output_buffer)?;
|
||||
|
||||
// Store current screen for next diff
|
||||
self.vt_prev_screen = Some(self.vt_parser.screen().clone());
|
||||
self.vt_render_pending = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||
@@ -375,48 +561,89 @@ impl Proxy {
|
||||
|
||||
for &byte in data {
|
||||
if self.in_lookback_mode && byte == 0x03 {
|
||||
self.input_buffer.clear();
|
||||
self.lookback_input_buffer.clear();
|
||||
self.exit_lookback_mode(stdout_fd)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
self.input_buffer.push(byte);
|
||||
let lookback_action = self.check_sequence_match(
|
||||
byte,
|
||||
&mut self.lookback_input_buffer.clone(),
|
||||
&self.config.lookback_sequence.clone(),
|
||||
);
|
||||
|
||||
if self.input_buffer.len() > self.config.lookback_sequence.len() {
|
||||
let excess = self.input_buffer.len() - self.config.lookback_sequence.len();
|
||||
if !self.in_lookback_mode {
|
||||
write_all(&self.pty_master, &self.input_buffer[..excess])?;
|
||||
}
|
||||
self.input_buffer.drain(..excess);
|
||||
self.lookback_input_buffer.push(byte);
|
||||
|
||||
if self.lookback_input_buffer.len() > self.config.lookback_sequence.len() {
|
||||
let excess = self.lookback_input_buffer.len() - self.config.lookback_sequence.len();
|
||||
self.lookback_input_buffer.drain(..excess);
|
||||
}
|
||||
|
||||
if self.input_buffer == self.config.lookback_sequence {
|
||||
self.input_buffer.clear();
|
||||
if self.in_lookback_mode {
|
||||
self.exit_lookback_mode(stdout_fd)?;
|
||||
} else {
|
||||
self.enter_lookback_mode()?;
|
||||
match lookback_action {
|
||||
SequenceMatch::Complete => {
|
||||
self.lookback_input_buffer.clear();
|
||||
if self.in_lookback_mode {
|
||||
self.exit_lookback_mode(stdout_fd)?;
|
||||
} else {
|
||||
self.enter_lookback_mode()?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else if !self
|
||||
.config
|
||||
.lookback_sequence
|
||||
.starts_with(&self.input_buffer)
|
||||
{
|
||||
if !self.in_lookback_mode {
|
||||
write_all(&self.pty_master, &self.input_buffer)?;
|
||||
SequenceMatch::Partial => {}
|
||||
SequenceMatch::None => {
|
||||
if !self
|
||||
.config
|
||||
.lookback_sequence
|
||||
.starts_with(&self.lookback_input_buffer)
|
||||
{
|
||||
self.lookback_input_buffer.clear();
|
||||
}
|
||||
}
|
||||
self.input_buffer.clear();
|
||||
}
|
||||
|
||||
if lookback_action == SequenceMatch::None && !self.in_lookback_mode {
|
||||
write_all(&self.pty_master, &[byte])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_sequence_match(
|
||||
&self,
|
||||
byte: u8,
|
||||
buffer: &mut Vec<u8>,
|
||||
sequence: &[u8],
|
||||
) -> SequenceMatch {
|
||||
buffer.push(byte);
|
||||
if buffer.len() > sequence.len() {
|
||||
let excess = buffer.len() - sequence.len();
|
||||
buffer.drain(..excess);
|
||||
}
|
||||
if buffer.as_slice() == sequence {
|
||||
SequenceMatch::Complete
|
||||
} else if sequence.starts_with(buffer) {
|
||||
SequenceMatch::Partial
|
||||
} else {
|
||||
SequenceMatch::None
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_lookback_mode(&mut self) -> Result<()> {
|
||||
debug!(
|
||||
"enter_lookback_mode: history_bytes={} lines={}",
|
||||
self.history.total_bytes(),
|
||||
self.history.line_count()
|
||||
);
|
||||
self.in_lookback_mode = true;
|
||||
self.lookback_cache.clear();
|
||||
self.vt_render_pending = false;
|
||||
|
||||
self.output_buffer.clear();
|
||||
self.history.append_all(&mut self.output_buffer);
|
||||
debug!(
|
||||
"enter_lookback_mode: output_buffer_len={}",
|
||||
self.output_buffer.len()
|
||||
);
|
||||
|
||||
let stdout_fd = io::stdout();
|
||||
write_all(&stdout_fd, CLEAR_SCREEN)?;
|
||||
@@ -433,25 +660,49 @@ impl Proxy {
|
||||
}
|
||||
|
||||
fn exit_lookback_mode<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||
debug!(
|
||||
"exit_lookback_mode: cached_len={}",
|
||||
self.lookback_cache.len()
|
||||
);
|
||||
self.in_lookback_mode = false;
|
||||
|
||||
// Process cached output through VT to update screen state
|
||||
let cached = std::mem::take(&mut self.lookback_cache);
|
||||
if !cached.is_empty() {
|
||||
debug!(
|
||||
"exit_lookback_mode: processing {} cached bytes",
|
||||
cached.len()
|
||||
);
|
||||
self.process_output(&cached, stdout_fd)?;
|
||||
}
|
||||
|
||||
self.output_buffer.clear();
|
||||
self.history.append_all(&mut self.output_buffer);
|
||||
// Reset sync block state
|
||||
self.in_sync_block = false;
|
||||
self.sync_buffer.clear();
|
||||
|
||||
write_all(stdout_fd, CLEAR_SCREEN)?;
|
||||
write_all(stdout_fd, CURSOR_HOME)?;
|
||||
write_all(stdout_fd, &self.output_buffer)?;
|
||||
self.forward_winsize()?;
|
||||
|
||||
// Force full render since terminal was showing history
|
||||
debug!("exit_lookback_mode: rendering VT screen");
|
||||
self.vt_prev_screen = None;
|
||||
self.render_vt_screen(stdout_fd)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn forward_winsize(&self) -> Result<()> {
|
||||
fn forward_winsize(&mut self) -> Result<()> {
|
||||
if let Ok(winsize) = get_terminal_size() {
|
||||
debug!(
|
||||
"forward_winsize: rows={} cols={}",
|
||||
winsize.ws_row, winsize.ws_col
|
||||
);
|
||||
// Resize VT emulator
|
||||
self.vt_parser
|
||||
.screen_mut()
|
||||
.set_size(winsize.ws_row, winsize.ws_col);
|
||||
// Force full render on next frame since size changed
|
||||
self.vt_prev_screen = None;
|
||||
// Forward to child process
|
||||
unsafe {
|
||||
libc::ioctl(
|
||||
self.pty_master.as_raw_fd(),
|
||||
@@ -493,7 +744,7 @@ fn get_terminal_size() -> Result<Winsize> {
|
||||
&mut ws,
|
||||
)
|
||||
};
|
||||
if ret == -1 {
|
||||
if ret == -1 || ws.ws_row == 0 || ws.ws_col == 0 {
|
||||
ws.ws_row = 24;
|
||||
ws.ws_col = 80;
|
||||
}
|
||||
|
||||
86
crates/claude-chill/src/redraw_throttler.rs
Normal file
86
crates/claude-chill/src/redraw_throttler.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct RedrawThrottler {
|
||||
min_interval: Duration,
|
||||
last_flush: Option<Instant>,
|
||||
pending_redraw: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl RedrawThrottler {
|
||||
pub fn new(min_interval_ms: u64) -> Self {
|
||||
Self {
|
||||
min_interval: Duration::from_millis(min_interval_ms),
|
||||
last_flush: None,
|
||||
pending_redraw: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&mut self, data: Vec<u8>) {
|
||||
self.pending_redraw = Some(data);
|
||||
}
|
||||
|
||||
pub fn should_flush(&self) -> bool {
|
||||
if self.pending_redraw.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self.last_flush {
|
||||
None => true,
|
||||
Some(last) => last.elapsed() >= self.min_interval,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_pending(&mut self) -> Option<Vec<u8>> {
|
||||
if self.should_flush() {
|
||||
self.last_flush = Some(Instant::now());
|
||||
self.pending_redraw.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time_until_next_flush(&self) -> Option<Duration> {
|
||||
self.pending_redraw.as_ref()?;
|
||||
|
||||
match self.last_flush {
|
||||
None => Some(Duration::ZERO),
|
||||
Some(last) => {
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed >= self.min_interval {
|
||||
Some(Duration::ZERO)
|
||||
} else {
|
||||
Some(self.min_interval - elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.pending_redraw.is_some()
|
||||
}
|
||||
|
||||
pub fn can_render(&self) -> bool {
|
||||
match self.last_flush {
|
||||
None => true,
|
||||
Some(last) => last.elapsed() >= self.min_interval,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_rendered(&mut self) {
|
||||
self.last_flush = Some(Instant::now());
|
||||
}
|
||||
|
||||
pub fn time_until_can_render(&self) -> Option<Duration> {
|
||||
match self.last_flush {
|
||||
None => Some(Duration::ZERO),
|
||||
Some(last) => {
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed >= self.min_interval {
|
||||
Some(Duration::ZERO)
|
||||
} else {
|
||||
Some(self.min_interval - elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user