Feature/vt (#6)

VT emulation instead of clipping sync blocks.
This commit is contained in:
David Beesley
2026-01-19 17:40:09 +13:00
committed by GitHub
parent c7ab4aa06b
commit a824eaea77
9 changed files with 594 additions and 139 deletions

148
Cargo.lock generated
View File

@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -58,6 +67,12 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -123,11 +138,14 @@ dependencies = [
"anyhow",
"clap",
"dirs",
"env_logger",
"libc",
"log",
"memchr",
"nix",
"serde",
"toml",
"vt100",
]
[[package]]
@@ -157,6 +175,29 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -202,6 +243,36 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "libc"
version = "0.2.177"
@@ -218,6 +289,12 @@ dependencies = [
"libc",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -248,6 +325,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "portable-atomic"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.105"
@@ -277,6 +369,35 @@ dependencies = [
"thiserror",
]
[[package]]
name = "regex"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "serde"
version = "1.0.228"
@@ -400,12 +521,39 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@@ -7,7 +7,7 @@
![Windows](https://img.shields.io/badge/Windows-unsupported-red)
![Rust](https://img.shields.io/badge/rust-2024-orange)
A PTY proxy that tames Claude Code's massive terminal updates.
A PTY proxy that tames Claude Code's massive terminal updates using VT-based rendering.
## The Problem
@@ -15,25 +15,13 @@ Claude Code uses synchronized output to update the terminal atomically. It wraps
The problem: Claude Code sends *entire* screen redraws in these sync blocks - often thousands of lines. Your terminal receives a 5000-line atomic update when only 20 lines are visible. This causes lag, flicker, and makes scrollback useless since each update clears history.
Analysis of terminal recordings confirms Claude Code wraps 100% of its output in sync blocks - every byte of visible output goes through synchronized updates.
Sync blocks start with one of three patterns (from a 3.5GB recording sample):
| Pattern | Count | Frequency | Avg Size |
|---------|-------|-----------|----------|
| Line clearing (`2K` + `1A` repeated) | 3,544 | 55% | 2.7 KB |
| Full screen clear (`2J` + `3J` + `H`) | 2,891 | 45% | 94.5 KB |
| CRLF + color codes | 1 | <1% | 2.0 KB |
The full screen clears are 35x larger than incremental line clears - these are the real problem.
## The Solution
claude-chill sits between your terminal and Claude Code:
1. **Intercepts sync blocks** - Catches those massive atomic updates
2. **Truncates full screen clears** - Only the 45% that are full redraws (94.5 KB avg) get truncated to the last N lines (default: 100). The 55% incremental updates (2.7 KB avg) pass through unchanged.
3. **Preserves history** - Accumulates content in a buffer. Clears on full screen clear, accumulates otherwise.
2. **VT-based rendering** - Uses a VT100 emulator to track screen state and renders only the differences
3. **Preserves history** - Accumulates content in a buffer for lookback
4. **Enables lookback** - Press a key to pause Claude and view the full history buffer
## Installation
@@ -47,7 +35,6 @@ cargo install --path crates/claude-chill
```bash
claude-chill claude
claude-chill -- claude --verbose # Use -- for command flags
claude-chill -l 50 -- claude # Set max lines to 50
```
## Lookback Mode
@@ -66,11 +53,12 @@ When you exit lookback mode, any cached output is processed and the current stat
Create `~/.config/claude-chill.toml`:
```toml
max_lines = 100 # Lines shown per sync block
history_lines = 100000 # Lines stored for lookback
history_lines = 100000 # Max lines stored for lookback
lookback_key = "[ctrl][6]"
```
Note: History is cleared on full screen redraws, so lookback shows output since Claude's last full render.
### Key Format
`[modifier][key]` - Examples: `[f12]`, `[ctrl][g]`, `[ctrl][shift][j]`
@@ -97,9 +85,9 @@ claude-chill creates a pseudo-terminal (PTY) and spawns Claude Code as a child p
1. **Input handling**: Keystrokes pass through to Claude, except for the lookback key which toggles lookback mode
2. **Output processing**: Scans output for sync block markers. Non-sync output passes through directly
3. **Sync block buffering**: Accumulates sync block content until the end marker arrives
4. **Truncation decision**: If the sync block contains a full screen clear (`ESC[2J` + `ESC[H`), truncates to the last N lines. Otherwise passes through unchanged
5. **History tracking**: Maintains a rolling buffer of output for lookback mode
3. **VT emulation**: Feeds output through a VT100 emulator to track the virtual screen state
4. **Differential rendering**: Compares current screen to previous and emits only the changes
5. **History tracking**: Maintains a buffer of output for lookback mode since the last full redraw
6. **Signal forwarding**: Window resize (SIGWINCH), interrupt (SIGINT), and terminate (SIGTERM) signals are forwarded to Claude
## Disclaimer

View File

@@ -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"

View File

@@ -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,

View File

@@ -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>,
}

View File

@@ -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]

View File

@@ -3,3 +3,4 @@ pub mod escape_sequences;
pub mod key_parser;
pub mod line_buffer;
pub mod proxy;
pub mod redraw_throttler;

View File

@@ -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);
if is_full_redraw {
self.create_truncated_output();
write_all(stdout_fd, &self.output_buffer)?;
} else {
write_all(stdout_fd, &self.sync_buffer)?;
self.sync_buffer.clear();
}
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();
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()?;
}
} else if !self
continue;
}
SequenceMatch::Partial => {}
SequenceMatch::None => {
if !self
.config
.lookback_sequence
.starts_with(&self.input_buffer)
.starts_with(&self.lookback_input_buffer)
{
if !self.in_lookback_mode {
write_all(&self.pty_master, &self.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;
}

View 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)
}
}
}
}
}