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

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

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